diff --git a/v1/README.md b/v1/README.md
index f1f4963..a57aa7f 100644
--- a/v1/README.md
+++ b/v1/README.md
@@ -11,7 +11,7 @@ This directory contains the templates for v1. Each template folder includes its
| [book_slate_recommendation](./book_slate_recommendation/) | Recommend K books per reader and order them by slot, under diversity, freshness, and explainability constraints. |
| [campaign_roi](./campaign_roi/) | Reallocate marketing campaign budgets across regions to maximize conversions, with per-campaign floor and cap constraints and a regional cap on a paused region. |
| [cell_tower_coverage](./cell_tower_coverage/) | Select candidate cell tower sites and assign demand zones to maximize covered population under budget, tower-count, and capacity limits. |
-| [cicd_runner_allocation](./cicd_runner_allocation/) | Assign CI/CD workflow jobs to the cheapest compatible runner type, subject to concurrency limits, with scenario analysis across capacity levels. |
+| [cicd_runner_allocation](./cicd_runner_allocation/) | Assign CI/CD workflow jobs to the cheapest compatible runner type, subject to concurrency limits, with scenario analysis across capacity levels and conflict analysis to diagnose an infeasible outage. |
| [commercial_underwriting](./commercial_underwriting/) | Run rules-based eligibility checks and risk-tier classification across a four-level commercial property/casualty hierarchy (insured entity, policy, location, coverage). |
| [datacenter_compute_allocation](./datacenter_compute_allocation/) | Multi-reasoner template (chain follow-up to energy_grid_planning): heterogeneous-graph GNN classification of per-workload utilization probability, hardware-compatibility rules, dependency PageRank, and 24-cell scenario MIP for inside-the-fence GPU allocation across hyperscaler campuses. |
| [demand_forecasting](./demand_forecasting/) | Forecast next-period unit sales per (store, item, day) with a regression GNN over a heterogeneous retail knowledge graph: sales transactions linked to stores, items, and item families so the GNN propagates signal through the store and product hierarchies. |
@@ -19,7 +19,7 @@ This directory contains the templates for v1. Each template folder includes its
| [diet](./diet/) | Select foods to satisfy nutritional requirements at minimum cost. |
| [disease-outbreak-prevention](./disease-outbreak-prevention/) | Use weighted degree centrality to identify the highest-risk healthcare facilities in a public health network, considering both connection volume and intensity, to prioritize resource deployment during disease outbreaks. |
| [energy_grid_planning](./energy_grid_planning/) | Multi-reasoner template: demand forecasting, grid vulnerability analysis, compliance rules, and multi-objective optimization for AI data center interconnection planning on the ERCOT (Texas) grid. |
-| [factory_production](./factory_production/) | Maximize profit from production with limited resource availability per factory. |
+| [factory_production](./factory_production/) | Maximize production profit under per-factory resource limits, then read the sensitivity marginals (capacity shadow prices and product reduced costs) from one solve. |
| [financial_index_replication](./financial_index_replication/) | Prescriptive optimization template for selecting a sparse 20-stock replication basket and weights that track an S&P 500-like benchmark. |
| [fraud-detection](./fraud-detection/) | Multi-reasoner transaction-fraud pipeline: account PageRank (Graph) + high-volume account flags (Rules) feed a GNN binary classifier (Predictive) whose per-transaction scores drive a knapsack investigator-budget MILP (Prescriptive). |
| [hospital_staffing](./hospital_staffing/) | Explore the tradeoff between overtime cost and patient service level using bi-objective optimization with epsilon constraint. |
@@ -42,7 +42,7 @@ This directory contains the templates for v1. Each template folder includes its
| [smoker_status_prediction](./smoker_status_prediction/) | Predict whether a person is a smoker from demographic and medical attributes plus a network of social connections, using a Graph Neural Network. |
| [sprint_scheduling](./sprint_scheduling/) | Assign backlog issues to developers across sprints, minimizing weighted completion time while respecting capacity and skill constraints. |
| [subscriber_retention](./subscriber_retention/) | Telco churn-risk scoring: PageRank over a Subscriber→Subscriber call graph (Graph) plus aggregate-derived call-volume features feed a regression GNN (Predictive) that scores per-subscriber churn risk, then surfaces the highest-risk subscribers per segment for retention campaigns. |
-| [supplier_reliability](./supplier_reliability/) | Select suppliers to meet product demand while balancing cost and reliability. |
+| [supplier_reliability](./supplier_reliability/) | Select suppliers to meet product demand at minimum cost, with sensitivity marginals and supplier-disruption scenario analysis. |
| [supply_chain_resilience](./supply_chain_resilience/) | A multi-reasoner template that chains blast-radius reachability, graph analysis, rules-based classification, and prescriptive optimization to build a risk-adjusted minimum-cost network flow for supply chain routing. |
| [supply_chain_transport](./supply_chain_transport/) | Minimize inventory holding and transport costs with TL/LTL mode selection. |
| [synthetic_eligibility_records](./synthetic_eligibility_records/) | Generate K distinct, internally consistent member eligibility records per solve using a CSP solver in multi-solution mode: each record satisfies CMS Medicare-eligibility, age-by-plan-type CFDs, and PCP-network attribution. |
diff --git a/v1/cicd_runner_allocation/README.md b/v1/cicd_runner_allocation/README.md
index 0908095..42ff6be 100644
--- a/v1/cicd_runner_allocation/README.md
+++ b/v1/cicd_runner_allocation/README.md
@@ -1,8 +1,8 @@
---
title: "CI/CD Runner Allocation"
-description: "Assign CI/CD workflow jobs to the cheapest compatible runner type, subject to concurrency limits, with scenario analysis across capacity levels."
+description: "Assign CI/CD workflow jobs to the cheapest compatible runner type, subject to concurrency limits, with scenario analysis across capacity levels and conflict analysis to diagnose an infeasible outage."
featured: false
-experience_level: beginner
+experience_level: intermediate
industry: "Software Engineering"
reasoning_types:
- Prescriptive
@@ -11,6 +11,7 @@ tags:
- Resource Allocation
- Cost Minimization
- Scenario Analysis
+ - Conflict Analysis
- CI/CD
- HiGHS
---
@@ -23,6 +24,8 @@ This template uses **prescriptive reasoning (optimization)** to assign CI/CD wor
The template also demonstrates **scenario analysis** by sweeping concurrency multipliers (0.5x, 1.0x, 1.5x) to show how pipeline cost changes under capacity constraints -- useful for evaluating maintenance windows, cost reduction, or burst provisioning.
+Finally, it shows **conflict analysis (infeasibility diagnosis)**. A maintenance outage takes two well-connected Linux runners offline, which funnels every high-CPU Linux job onto the one surviving large runner -- whose concurrency cap cannot hold them all, so the schedule is impossible. Rather than just reporting "infeasible," the outage solve requests `solve(conflict=True)`, which returns an **irreducible infeasible subsystem (IIS)**: the minimal set of rules that cannot all hold at once. The diagnosis names the *stranded jobs* and the *binding runner cap*, so an operator knows exactly which cap to raise or which runner to restore.
+
## Who this is for
- DevOps engineers optimizing CI/CD runner costs
@@ -35,6 +38,7 @@ The template also demonstrates **scenario analysis** by sweeping concurrency mul
- Per-runner concurrency constraints scaled by a scenario parameter
- Cost minimization objective (runner cost_per_minute * job estimated_minutes)
- Scenario comparison showing cost impact of halving or increasing runner capacity
+- A maintenance-outage diagnosis that reads the IIS (stranded jobs + binding cap) back by entity key
## What's included
@@ -52,7 +56,7 @@ The template also demonstrates **scenario analysis** by sweeping concurrency mul
### Tools
- Python >= 3.10
-- RelationalAI Python SDK (`relationalai`) >= 1.0.14
+- RelationalAI Python SDK (`relationalai`) >= 1.9.0
## Quickstart
@@ -87,7 +91,7 @@ The template also demonstrates **scenario analysis** by sweeping concurrency mul
python cicd_runner_allocation.py
```
-6. Expected output:
+6. Expected output (representative -- equal-cost runners may be swapped between tied optima, and the IIS may name a different six of the seven stranded jobs; the statuses, costs, and binding runner cap are stable):
```text
Running scenario: concurrency_multiplier = 0.5
--------------------------------------------------
@@ -98,9 +102,9 @@ The template also demonstrates **scenario analysis** by sweeping concurrency mul
macos-large (1 jobs): ios-testflight
macos-latest (2 jobs): build-mobile-ios, e2e-tests-safari
self-hosted-linux (4 jobs): e2e-tests-chrome, integration-tests, nightly-build, performance-tests
- ubuntu-22.04 (4 jobs): build-api, dependency-audit, lint-and-format, release-notes
+ ubuntu-22.04 (2 jobs): build-api, release-notes
ubuntu-large (3 jobs): build-mobile-android, docker-build, unit-tests-api
- ubuntu-latest (5 jobs): build-frontend, deploy-production, deploy-staging, security-scan, unit-tests-frontend
+ ubuntu-latest (7 jobs): build-frontend, dependency-audit, deploy-production, deploy-staging, lint-and-format, security-scan, unit-tests-frontend
windows-latest (1 jobs): windows-installer
Running scenario: concurrency_multiplier = 1.0
@@ -112,8 +116,8 @@ The template also demonstrates **scenario analysis** by sweeping concurrency mul
macos-large (1 jobs): ios-testflight
macos-latest (2 jobs): build-mobile-ios, e2e-tests-safari
self-hosted-linux (8 jobs): build-api, build-mobile-android, docker-build, e2e-tests-chrome, integration-tests, nightly-build, performance-tests, unit-tests-api
- ubuntu-22.04 (3 jobs): dependency-audit, lint-and-format, release-notes
- ubuntu-latest (5 jobs): build-frontend, deploy-production, deploy-staging, security-scan, unit-tests-frontend
+ ubuntu-22.04 (1 jobs): release-notes
+ ubuntu-latest (7 jobs): build-frontend, dependency-audit, deploy-production, deploy-staging, lint-and-format, security-scan, unit-tests-frontend
windows-latest (1 jobs): windows-installer
Running scenario: concurrency_multiplier = 1.5
@@ -125,8 +129,8 @@ The template also demonstrates **scenario analysis** by sweeping concurrency mul
macos-large (1 jobs): ios-testflight
macos-latest (2 jobs): build-mobile-ios, e2e-tests-safari
self-hosted-linux (12 jobs): build-api, build-frontend, build-mobile-android, dependency-audit, docker-build, e2e-tests-chrome, integration-tests, nightly-build, performance-tests, security-scan, unit-tests-api, unit-tests-frontend
- ubuntu-22.04 (2 jobs): deploy-staging, release-notes
- ubuntu-latest (2 jobs): deploy-production, lint-and-format
+ ubuntu-22.04 (2 jobs): deploy-production, release-notes
+ ubuntu-latest (2 jobs): deploy-staging, lint-and-format
windows-latest (1 jobs): windows-installer
==================================================
@@ -135,13 +139,50 @@ The template also demonstrates **scenario analysis** by sweeping concurrency mul
concurrency_multiplier=0.5: OPTIMAL, cost=$10.18
concurrency_multiplier=1.0: OPTIMAL, cost=$9.62
concurrency_multiplier=1.5: OPTIMAL, cost=$9.53
+
+ ==================================================
+ Maintenance outage: ubuntu-large, self-hosted-linux offline
+ ==================================================
+ Solve result:
+ • status: INFEASIBLE
+ • primal status: NO_SOLUTION
+ • dual status: INFEASIBILITY_CERTIFICATE
+ • conflict status: CONFLICT_FOUND
+ ...
+
+ Stranded jobs (assign-one rule in conflict):
+ workflow
+ build-mobile-android
+ docker-build
+ e2e-tests-chrome
+ integration-tests
+ performance-tests
+ unit-tests-api
+
+ Binding runner caps (concurrency rule in conflict):
+ runner max_concurrent
+ ubuntu-xlarge 5
+
+ To restore feasibility, relax one member of the conflict: bring ubuntu-large or
+ self-hosted-linux back online, or raise ubuntu-xlarge's concurrency cap. ...
```
At full capacity (1.0x), self-hosted-linux absorbs 8 of 20 jobs at
$0.005/min -- the cheapest runner. At half capacity (0.5x), its 4-job
cap forces overflow to ubuntu-large and ubuntu-22.04, raising cost by
6%. Burst mode (1.5x) pushes 12 jobs to self-hosted, saving another
- $0.09 by avoiding the more expensive ubuntu runners entirely.
+ $0.09 by pulling four more low-CPU jobs off the pricier ubuntu runners
+ (the high-CPU jobs already fit on self-hosted at 1.0x). (How the cheap,
+ low-CPU jobs split between the two equal-cost ubuntu runners is one of several
+ tied optima -- a different HiGHS build may place them differently at the same
+ total cost.)
+
+ **The maintenance outage** is infeasible: with `ubuntu-large` and
+ `self-hosted-linux` offline, the seven high-CPU Linux jobs can only run on
+ `ubuntu-xlarge` (cap 5). `solve(conflict=True)` returns the IIS -- six of the
+ seven stranded jobs plus the `ubuntu-xlarge` concurrency rule (which six is
+ solver-dependent, since any six already exceed the cap). The diagnosis points an
+ operator straight at the fix: restore a runner or raise that one cap.
## Template structure
@@ -169,6 +210,7 @@ Runner = model.Concept("Runner", identify_by={"runner_id": Integer})
Runner.name = model.Property(f"{Runner} has {String:runner_name}")
Runner.os = model.Property(f"{Runner} has {String:runner_os}")
Runner.cpu = model.Property(f"{Runner} has {Integer:cpu}")
+Runner.memory_gb = model.Property(f"{Runner} has {Integer:memory_gb}")
Runner.cost_per_minute = model.Property(f"{Runner} has {Float:cost_per_minute}")
Runner.max_concurrent = model.Property(f"{Runner} has {Integer:max_concurrent}")
```
@@ -177,8 +219,11 @@ Runner.max_concurrent = model.Property(f"{Runner} has {Integer:max_concurrent}")
```python
Workflow = model.Concept("Workflow", identify_by={"workflow_id": Integer})
+Workflow.name = model.Property(f"{Workflow} has {String:workflow_name}")
+Workflow.event = model.Property(f"{Workflow} has {String:workflow_event}")
Workflow.required_os = model.Property(f"{Workflow} has {String:required_os}")
Workflow.min_cpu = model.Property(f"{Workflow} has {Integer:min_cpu}")
+Workflow.min_memory_gb = model.Property(f"{Workflow} has {Integer:min_memory_gb}")
Workflow.estimated_minutes = model.Property(
f"{Workflow} has {Integer:estimated_minutes}"
)
@@ -212,24 +257,29 @@ problem.solve_for(
)
```
-Two constraints enforce feasibility. First, each workflow must be assigned to exactly one runner:
+Two constraints enforce feasibility. Each is **captured as a handle**, **named per entity** (a readable label), and declared with **`keyed_by`** -- the entity key its conflict membership reads back through if the model turns out infeasible. First, each workflow must be assigned to exactly one runner:
```python
-problem.satisfy(model.require(
- sum(AssignRef.x_assigned)
- .where(AssignRef.workflow == Workflow)
- .per(Workflow) == 1
-))
+assign_one = problem.satisfy(
+ model.require(
+ sum(AssignRef.x_assigned).where(AssignRef.workflow == Workflow).per(Workflow) == 1
+ ),
+ name=["assign_one", Workflow.name],
+ keyed_by={"workflow": Workflow},
+)
```
Second, the number of workflows assigned to each runner cannot exceed its concurrency limit, scaled by the scenario multiplier:
```python
-problem.satisfy(model.require(
- sum(AssignRef.x_assigned)
- .where(AssignRef.runner == Runner)
- .per(Runner) <= concurrency_multiplier * Runner.max_concurrent
-))
+conc = problem.satisfy(
+ model.require(
+ sum(AssignRef.x_assigned).where(AssignRef.runner == Runner).per(Runner)
+ <= concurrency_multiplier * Runner.max_concurrent
+ ),
+ name=["concurrency", Runner.name],
+ keyed_by={"runner": Runner},
+)
```
The objective minimizes total pipeline cost -- the sum of (runner cost per minute * job duration) across all assignments:
@@ -252,7 +302,7 @@ The script loops over three concurrency multipliers (0.5x, 1.0x, 1.5x), creating
SCENARIO_VALUES = [0.5, 1.0, 1.5]
for multiplier in SCENARIO_VALUES:
- result = solve_allocation(multiplier)
+ alloc = solve_allocation(multiplier)
```
After all scenarios, a summary table compares status and cost:
@@ -263,6 +313,45 @@ for r in scenario_results:
f"{r['status']}, cost=${r['objective']:.2f}")
```
+### Diagnose a maintenance outage with conflict analysis
+
+The final section models a maintenance outage: `ubuntu-large` and `self-hosted-linux` go offline (their assignments are dropped with a `where=` filter). Every high-CPU Linux job (`min_cpu >= 4`) is compatible only with runners in `{ubuntu-large, ubuntu-xlarge, self-hosted-linux}` (the two heaviest jobs with just the latter two), so with two of those three down, all seven funnel onto `ubuntu-xlarge` -- whose concurrency cap of 5 cannot hold them. The solve requests a conflict diagnosis:
+
+```python
+outage = solve_allocation(1.0, offline_runners=["ubuntu-large", "self-hosted-linux"], conflict=True)
+
+assert outage.si.conflict is True
+assert outage.si.termination_status in ("INFEASIBLE", "INFEASIBLE_OR_UNBOUNDED")
+
+# conflict_status gates whether an IIS is available -- dispatch on it.
+if outage.si.conflict_status == "CONFLICT_FOUND":
+ ... # read the stranded jobs and the binding cap (below)
+else:
+ # NO_CONFLICT_EXISTS (the model was feasible) or NOT_SUPPORTED / FAILED (this solver
+ # build produced no IIS, e.g. needs HiGHS >= 1.13) -- report the status, don't read it.
+ print(f"No IIS to inspect: {outage.si.conflict_status}")
+```
+
+(The template's own `else` branch raises instead of printing: its outage is infeasible by construction, so a missing IIS there is a regression. The `print` form above is the one to copy when infeasibility is not guaranteed.)
+
+`in_conflict` is a bare predicate on each constraint instance -- true when the solver reports that instance in the conflict (it collapses the solver's `IN_CONFLICT` and `MAYBE_IN_CONFLICT` into one membership). Each constraint's declared key gives it an **entity back-pointer** (`assign_one.workflow`, `conc.runner`), mirroring the variable's automatic back-pointer, so the conflict reads back as the actual stranded jobs and the binding runner cap, joined by KEY -- no rule-name parsing:
+
+```python
+# Stranded jobs (their assign-one rule is in the conflict):
+model.select(outage.assign_one.workflow.name).where(outage.assign_one.in_conflict).inspect()
+# The binding runner cap (its concurrency rule is in the conflict):
+model.select(outage.conc.runner.name, outage.conc.runner.max_concurrent).where(
+ outage.conc.in_conflict
+).inspect()
+```
+
+(`.inspect()` prints the rows for a quick look; the script materializes the same selects as DataFrames with `.to_df()` for its printed report and assertions.)
+
+The IIS is minimal: it names **six of the seven** high-CPU jobs (any six already exceed the cap of five, so which six is solver-dependent) plus the `ubuntu-xlarge` concurrency rule. To restore feasibility, relax one member -- bring a runner back online or raise the cap. Because all seven jobs share the one survivor, lift the cap enough for all of them (or restore a runner) and re-solve to confirm; clearing a single job only resolves that one row of the conflict.
+
+> [!NOTE]
+> Conflict analysis works for mixed-integer models like this one (unlike sensitivity analysis, which needs an LP/QP). It requires no objective -- it diagnoses feasibility. Request `conflict=True` on the solve whose infeasibility you want to explain -- up front, or on a fresh build: a `Problem` already solved without it cannot add it on a re-solve.
+
## Customize this template
- **Add runners**: Extend `runners.csv` with new runner types (e.g., GPU runners for ML workflows).
@@ -276,7 +365,7 @@ for r in scenario_results:
Problem is infeasible
-The concurrency limits are too tight for the number of workflows. Increase `max_concurrent` in `runners.csv`, reduce the number of workflows, or increase the concurrency multiplier in `SCENARIO_VALUES`.
+The concurrency limits are too tight for the number of workflows. Rather than guess, request a conflict diagnosis -- `solve(conflict=True)` returns the irreducible infeasible subsystem (the stranded jobs and the binding runner cap), as shown in the maintenance-outage section. Then increase `max_concurrent` for the named runner in `runners.csv`, reduce the number of workflows competing for it, or raise the concurrency multiplier.
diff --git a/v1/cicd_runner_allocation/cicd_runner_allocation.py b/v1/cicd_runner_allocation/cicd_runner_allocation.py
index 5605db6..7e883e0 100644
--- a/v1/cicd_runner_allocation/cicd_runner_allocation.py
+++ b/v1/cicd_runner_allocation/cicd_runner_allocation.py
@@ -1,6 +1,7 @@
"""CI/CD runner allocation (prescriptive optimization) template.
-This script demonstrates a resource assignment optimization in RelationalAI:
+This script demonstrates a resource assignment optimization in RelationalAI, then
+diagnoses what makes a maintenance outage *unschedulable*:
- Load sample CSVs describing CI/CD runners, workflow jobs, and compatibility.
- Model runners, workflows, and assignments as concepts with typed properties.
@@ -9,14 +10,29 @@
- Respect per-runner concurrency limits.
- Compare costs across budget scenarios with different concurrency caps.
+Then a **maintenance outage** takes two well-connected Linux runners offline. That
+funnels every high-CPU Linux job onto the one surviving large runner, whose
+concurrency cap cannot hold them all -- the model is infeasible. "Infeasible" alone
+is not actionable, so the outage solve requests ``solve(conflict=True)``, which
+computes an irreducible infeasible subsystem (IIS): a small set of rules that cannot
+all hold at once. ``in_conflict`` is a bare predicate on each constraint instance --
+true when the solver reports that instance in the conflict (it collapses the solver's
+IN_CONFLICT and MAYBE_IN_CONFLICT into a single membership). Each constraint is declared
+with ``keyed_by``, so it carries an entity back-pointer to what it grounds over
+(``assign_one.workflow`` / ``conc.runner``) and the conflict reads back as the actual
+*stranded jobs* and the *binding runner cap*, joined by KEY -- no rule-name parsing.
+
Run:
`python cicd_runner_allocation.py`
Output:
- Prints per-scenario solver status, total pipeline cost, and the
- runner-to-workflow assignment table showing which runner handles each job.
+ Per-scenario solver status, total pipeline cost, and the runner-to-workflow
+ assignment table; then the maintenance-outage diagnosis naming the stranded
+ jobs and the binding concurrency cap.
"""
+import warnings
+from collections import namedtuple
from pathlib import Path
from pandas import read_csv
@@ -118,65 +134,134 @@
# Solve with scenario analysis (concurrency multiplier)
# --------------------------------------------------
-def solve_allocation(concurrency_multiplier):
- """Solve runner assignment with a given concurrency cap multiplier.
+# Handles returned by a solve: the solve_info plus the variable and the two named
+# constraint families, so callers can read assignments (feasible solves, via
+# assign_var) or IIS membership (the conflict=True outage, via assign_one / conc)
+# by entity key.
+Allocation = namedtuple("Allocation", "si assign_var assign_one conc")
+
+
+def solve_allocation(concurrency_multiplier, offline_runners=(), conflict=False):
+ """Solve runner assignment under a concurrency cap.
- Returns (solve_info, assignment_df) or None if infeasible.
+ ``offline_runners`` takes the named runners offline (a maintenance outage) by
+ excluding their assignments. ``conflict=True`` requests an IIS diagnosis on the
+ same solve. Returns an ``Allocation`` with the named constraint handles.
"""
- problem = Problem(model, Float)
-
- # Decision variable: binary assignment of workflow to runner.
- assign_var = problem.solve_for(
- Assignment.x_assigned,
- type="bin",
- name=["assign", Assignment.workflow.name, Assignment.runner.name],
- populate=False,
- )
+ # This builder runs once per scenario (the concurrency sweep, then the outage).
+ # Rebuilding the Problem -- with its per-workflow and per-runner *named*
+ # constraints -- that many times trips PyRel's "rules created in a loop"
+ # heuristic. The pattern is intentional here (re-solving with a different cap is
+ # the point) and harmless at this size, and the per-instance names are what let
+ # the IIS read back by entity key. RAI diagnostics route through Python's
+ # warnings module, so silence just that one message, scoped to this builder --
+ # warning state outside it is untouched.
+ with warnings.catch_warnings():
+ warnings.filterwarnings("ignore", message=r"\[Rules created in a loop\]")
+
+ problem = Problem(model, Float)
+
+ # Decision variable: binary assignment of workflow to runner. A maintenance
+ # outage drops the offline runners' assignments via where=. With no offline
+ # runners the comprehension is empty and `[] or None` collapses to None --
+ # solve_for treats an empty where= and None the same ("no filter"); the
+ # `or None` just makes the no-filter case explicit.
+ where_clause = [Assignment.runner.name != r for r in offline_runners] or None
+ assign_var = problem.solve_for(
+ Assignment.x_assigned,
+ type="bin",
+ name=["assign", Assignment.workflow.name, Assignment.runner.name],
+ where=where_clause,
+ populate=False,
+ )
- AssignRef = Assignment.ref()
-
- # Constraint: each workflow assigned to exactly one runner.
- problem.satisfy(model.require(
- sum(AssignRef.x_assigned)
- .where(AssignRef.workflow == Workflow)
- .per(Workflow) == 1
- ))
-
- # Constraint: per-runner concurrency limit (scaled by scenario multiplier).
- problem.satisfy(model.require(
- sum(AssignRef.x_assigned)
- .where(AssignRef.runner == Runner)
- .per(Runner) <= concurrency_multiplier * Runner.max_concurrent
- ))
-
- # Objective: minimize total pipeline cost.
- problem.minimize(
- sum(
- Assignment.x_assigned
- * Assignment.runner.cost_per_minute
- * Assignment.workflow.estimated_minutes
+ AssignRef = Assignment.ref()
+
+ # Constraint: each workflow assigned to exactly one runner. ``keyed_by`` declares
+ # the workflow key, so IIS membership reads back by it (assign_one.workflow); the
+ # per-workflow name is a readable label.
+ assign_one = problem.satisfy(
+ model.require(
+ sum(AssignRef.x_assigned).where(AssignRef.workflow == Workflow).per(Workflow) == 1
+ ),
+ name=["assign_one", Workflow.name],
+ keyed_by={"workflow": Workflow},
)
- )
- problem.solve("highs", time_limit_sec=60)
- si = problem.solve_info()
+ # Constraint: per-runner concurrency limit (scaled by scenario multiplier).
+ # ``keyed_by`` declares the runner key (conc.runner).
+ conc = problem.satisfy(
+ model.require(
+ sum(AssignRef.x_assigned).where(AssignRef.runner == Runner).per(Runner)
+ <= concurrency_multiplier * Runner.max_concurrent
+ ),
+ name=["concurrency", Runner.name],
+ keyed_by={"runner": Runner},
+ )
- if si.termination_status not in ("OPTIMAL", "LOCALLY_SOLVED"):
- return None
+ # Objective: minimize total pipeline cost.
+ problem.minimize(
+ sum(
+ Assignment.x_assigned
+ * Assignment.runner.cost_per_minute
+ * Assignment.workflow.estimated_minutes
+ )
+ )
- value_ref = Float.ref()
- assign_df = model.select(
- assign_var.assignment.workflow.name.alias("workflow"),
- assign_var.assignment.runner.name.alias("runner"),
- ).where(assign_var.values(0, value_ref), value_ref > 0.5).to_df()
+ problem.solve("highs", time_limit_sec=60, conflict=conflict)
+ return Allocation(problem.solve_info(), assign_var, assign_one, conc)
- return si, assign_df
+
+def assignment_df(assign_var):
+ """The chosen (workflow, runner) assignments, read off the variable by key."""
+ value_ref = Float.ref()
+ return (
+ model.select(
+ assign_var.assignment.workflow.name.alias("workflow"),
+ assign_var.assignment.runner.name.alias("runner"),
+ )
+ .where(assign_var.values(0, value_ref), value_ref > 0.5)
+ .to_df()
+ )
# --------------------------------------------------
# Main execution
# --------------------------------------------------
+# Maintenance outage: take two well-connected Linux runners offline. Every high-CPU
+# Linux job (min_cpu >= 4) is compatible only with runners in {ubuntu-large,
+# ubuntu-xlarge, self-hosted-linux} (the two min_cpu=8 jobs with just the latter
+# two); with ubuntu-large and self-hosted-linux down, all seven funnel onto the one
+# survivor -- whose concurrency cap cannot hold them.
+OFFLINE_RUNNERS = ["ubuntu-large", "self-hosted-linux"]
+HIGH_CPU_LINUX_JOBS = {
+ "build-mobile-android",
+ "unit-tests-api",
+ "integration-tests",
+ "e2e-tests-chrome",
+ "docker-build",
+ "performance-tests",
+ "nightly-build",
+}
+# Guard against CSV drift: this set must stay equal to the data's actual high-CPU Linux
+# jobs (min_cpu >= 4 on a Linux runner), so editing workflows.csv can't silently
+# invalidate the IIS assertions below.
+assert HIGH_CPU_LINUX_JOBS == set(
+ workflow_csv.loc[
+ (workflow_csv["min_cpu"] >= 4) & (workflow_csv["required_os"] == "linux"),
+ "name",
+ ]
+)
+# Same guard for the runner side: the outage runners and the surviving cap asserted
+# below must exist in runners.csv, or the where= exclusion silently excludes nothing
+# and the outage stays feasible.
+assert set(OFFLINE_RUNNERS) | {"ubuntu-xlarge"} <= set(runner_csv["name"]), (
+ f"runners.csv changed: expected {sorted(set(OFFLINE_RUNNERS) | {'ubuntu-xlarge'})} "
+ f"among {sorted(runner_csv['name'])}"
+)
+
+
if __name__ == "__main__":
scenario_results = []
@@ -185,33 +270,37 @@ def solve_allocation(concurrency_multiplier):
print(f"\nRunning scenario: {SCENARIO_PARAM} = {multiplier}")
print("-" * 50)
- result = solve_allocation(multiplier)
-
- if result is None:
- print(" Status: INFEASIBLE -- skipping results")
- scenario_results.append({
- "scenario": multiplier,
- "status": "INFEASIBLE",
- "objective": None,
- })
+ alloc = solve_allocation(multiplier)
+
+ if alloc.si.termination_status != "OPTIMAL":
+ print(f" Status: {alloc.si.termination_status} -- skipping results")
+ scenario_results.append(
+ {
+ "scenario": multiplier,
+ "status": str(alloc.si.termination_status),
+ "objective": None,
+ }
+ )
continue
- si, assign_df = result
- print(f" Status: {si.termination_status}")
- print(f" Total pipeline cost: ${si.objective_value:.2f}")
+ print(f" Status: {alloc.si.termination_status}")
+ print(f" Total pipeline cost: ${alloc.si.objective_value:.2f}")
# Print assignments grouped by runner.
+ assign_df = assignment_df(alloc.assign_var)
print("\n Assignments:")
for runner_name in sorted(assign_df["runner"].unique()):
jobs = assign_df[assign_df["runner"] == runner_name]
workflows = ", ".join(sorted(jobs["workflow"]))
print(f" {runner_name} ({len(jobs)} jobs): {workflows}")
- scenario_results.append({
- "scenario": multiplier,
- "status": si.termination_status,
- "objective": si.objective_value,
- })
+ scenario_results.append(
+ {
+ "scenario": multiplier,
+ "status": str(alloc.si.termination_status),
+ "objective": alloc.si.objective_value,
+ }
+ )
# --------------------------------------------------
# Scenario comparison
@@ -227,3 +316,83 @@ def solve_allocation(concurrency_multiplier):
f"{r['status']}, cost=${r['objective']:.2f}")
else:
print(f" {SCENARIO_PARAM}={r['scenario']}: {r['status']}")
+
+ # --------------------------------------------------
+ # Maintenance outage: diagnose the infeasibility (conflict / IIS)
+ # --------------------------------------------------
+
+ print(f"\n{'=' * 50}")
+ print(f"Maintenance outage: {', '.join(OFFLINE_RUNNERS)} offline")
+ print("=" * 50)
+
+ outage = solve_allocation(1.0, offline_runners=OFFLINE_RUNNERS, conflict=True)
+ outage.si.display()
+
+ assert outage.si.conflict is True
+ # An infeasible model may be reported as INFEASIBLE or INFEASIBLE_OR_UNBOUNDED.
+ assert outage.si.termination_status in ("INFEASIBLE", "INFEASIBLE_OR_UNBOUNDED")
+
+ # conflict_status gates whether an IIS is available to read. A copyable diagnostic
+ # dispatches on it -- inspect the conflict only for CONFLICT_FOUND, and report the
+ # reason for the other documented outcomes -- rather than reading an empty IIS. This
+ # model is built infeasible on purpose, so on a solver with MIP conflict support
+ # (HiGHS >= 1.13) we expect CONFLICT_FOUND; the else branch is therefore also this
+ # template's regression guard. (When you copy this for a model whose infeasibility is
+ # not guaranteed, swap the raise for a print/return.)
+ if outage.si.conflict_status == "CONFLICT_FOUND":
+ # in_conflict is a bare predicate on each constraint instance; the declared
+ # entity key (assign_one.workflow / conc.runner) joins the IIS to the actual
+ # stranded jobs and the binding runner cap -- no rule-name parsing.
+ print("\nStranded jobs (assign-one rule in conflict):")
+ stranded_df = (
+ model.select(outage.assign_one.workflow.name.alias("workflow"))
+ .where(outage.assign_one.in_conflict)
+ .to_df()
+ .sort_values("workflow", ignore_index=True)
+ )
+ print(stranded_df.to_string(index=False))
+
+ print("\nBinding runner caps (concurrency rule in conflict):")
+ caps_df = (
+ model.select(
+ outage.conc.runner.name.alias("runner"),
+ outage.conc.runner.max_concurrent.alias("max_concurrent"),
+ )
+ .where(outage.conc.in_conflict)
+ .to_df()
+ .sort_values("runner", ignore_index=True)
+ )
+ print(caps_df.to_string(index=False))
+
+ stranded = set(stranded_df["workflow"])
+ caps = set(caps_df["runner"])
+
+ # The binding capacity is ubuntu-xlarge -- the only surviving cpu>=4 Linux runner,
+ # and the sole runner cap in the IIS. (This <= constraint is the tested IIS path.)
+ assert caps == {"ubuntu-xlarge"}
+ # The stranded jobs are the high-CPU Linux jobs funneled onto it. A minimal IIS
+ # names cap+1 = 6 of the seven (which six is solver-dependent), so assert the
+ # provable lower bound and a subset rather than an exact set. This also
+ # exercises in_conflict on the equality (== 1) rows, not just the <=
+ # concurrency row.
+ assert len(stranded) >= 6, (
+ f"expected >= 6 stranded jobs (cap+1), got {len(stranded)}: {sorted(stranded)} "
+ "-- check in_conflict on '== 1' rows"
+ )
+ assert stranded.issubset(HIGH_CPU_LINUX_JOBS)
+
+ print(
+ "\nTo restore feasibility, relax one member of the conflict: bring "
+ "ubuntu-large or self-hosted-linux back online, or raise ubuntu-xlarge's "
+ "concurrency cap. All seven high-CPU jobs share the one survivor, so lift the "
+ "cap (or restore a runner) enough for all of them and re-solve to confirm -- "
+ "clearing a single stranded job only resolves that row of the conflict."
+ )
+ else:
+ # NO_CONFLICT_EXISTS => the model was feasible; NOT_SUPPORTED / FAILED => this
+ # solver build produced no IIS (needs HiGHS >= 1.13 with MIP conflict support).
+ raise AssertionError(
+ f"expected CONFLICT_FOUND for this deliberately-infeasible model, got "
+ f"{outage.si.conflict_status} -- conflict analysis needs HiGHS >= 1.13 "
+ "with MIP IIS support"
+ )
diff --git a/v1/cicd_runner_allocation/pyproject.toml b/v1/cicd_runner_allocation/pyproject.toml
index 598a28b..24f3b7b 100644
--- a/v1/cicd_runner_allocation/pyproject.toml
+++ b/v1/cicd_runner_allocation/pyproject.toml
@@ -9,7 +9,7 @@ description = "RelationalAI template: cicd_runner_allocation (PyRel v1)"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
- "relationalai==1.0.14",
+ "relationalai==1.9.0",
"pandas>=2.0",
]
diff --git a/v1/factory_production/README.md b/v1/factory_production/README.md
index 2a2d32b..f508325 100644
--- a/v1/factory_production/README.md
+++ b/v1/factory_production/README.md
@@ -1,8 +1,8 @@
---
title: "Factory Production"
-description: "Maximize profit from production with limited resource availability per factory."
+description: "Maximize production profit under per-factory resource limits, then read the sensitivity marginals (capacity shadow prices and product reduced costs) from one solve."
featured: false
-experience_level: beginner
+experience_level: intermediate
industry: "Manufacturing"
reasoning_types:
- Prescriptive
@@ -10,43 +10,49 @@ tags:
- Linear Programming
- Profit Maximization
- Resource Allocation
- - Scenario Analysis
+ - Sensitivity Analysis
+ - Shadow Prices
---
# Factory Production
## What this template is for
-This template uses **Prescriptive** reasoning to maximize factory production profit under resource constraints. It is the recommended starting point for prescriptive optimization in this portfolio — the simplest end-to-end LP template before moving on to multi-machine scheduling and multi-period planning.
+This template uses **Prescriptive** reasoning to maximize factory production profit under resource constraints, and then to read back the **sensitivity marginals** a planner asks next -- all from a single solve. It is a compact, textbook **product-mix linear program**: a small, fully hand-checkable setting for learning what shadow prices and reduced costs mean.
Manufacturing operations must decide how much of each product to produce at each factory to maximize profit, given limited resources and bounded demand. Each product has a production rate (units per hour of resource), a profit per unit, and a maximum demand. Each factory has a fixed number of available resource-hours.
-This template formulates the problem as a linear program. Decision variables represent the quantity of each product to produce. Constraints ensure that total resource usage at each factory does not exceed availability and that production does not exceed demand. The objective maximizes total profit.
+This template formulates the problem as a linear program. Decision variables represent the quantity of each product to produce, bounded above by demand. A per-factory constraint keeps total resource usage within availability, and the objective maximizes total profit.
-The template solves the problem independently per factory, demonstrating a scenario-based approach where each factory is treated as a separate optimization.
+A plain solve answers *"what is the most profitable production plan?"*. Adding `sensitivity=True` to the solve ALSO answers the marginal questions -- in the same solve, with the answers read straight off the variable and constraint objects:
+
+- **Capacity shadow price** (`cap.shadow_price`): how much total profit moves per extra hour at a factory. A capacity with idle hours prices at **zero** (it is not the bottleneck); a positive price flags a binding capacity worth expanding -- so this ranks which factory to expand first. (The rule is one-way: slack ⇒ zero price, and a positive price ⇒ binding.)
+- **Product reduced cost** and **basis status** (`quantity_var.reduced_cost` / `quantity_var.basis_status`): a product held at its demand cap shows a positive reduced cost here (the extra profit per unit of demand allowed); the swing product that sets the binding factory's marginal price is `BASIC` at ~0.
+
+Because the objective is a **maximization**, the *capacity shadow price* is the mirror image of a minimize-cost model -- a binding `<=` capacity prices `>= 0` here, versus `<= 0` in the cost-minimizing [`supplier_reliability`](../supplier_reliability/) template. The nonbasic bound marginals shown in *both* tables are non-negative; what flips for the *product* marginal is the active bound -- a demand-capped product sits at its upper bound (`NONBASIC_AT_UPPER`) here, while a priced-out supply lane sits at its lower bound of zero (`NONBASIC_AT_LOWER`) there. Laying the two reduced-cost tables side by side is the fastest way to internalize the conventions.
> **Production-planning learning ladder**
-> 1. **Factory Production** *(this template)* — single-period LP, profit max, scenario-per-factory.
-> 2. [`production_planning`](../production_planning/) — multi-machine assignment with integer decisions and demand multipliers.
-> 3. [`demand_planning_temporal`](../demand_planning_temporal/) — multi-period production + inventory across sites with date-filtered planning horizon.
+> 1. **Factory Production** *(this template)* -- single-period product-mix LP with sensitivity analysis.
+> 2. [`production_planning`](../production_planning/) -- multi-machine assignment with integer decisions and demand multipliers.
+> 3. [`demand_planning_temporal`](../demand_planning_temporal/) -- multi-period production + inventory across sites with date-filtered planning horizon.
## Who this is for
-- Manufacturing planners optimizing production schedules
-- Operations researchers learning resource-constrained profit maximization
-- Data scientists exploring factory-level scenario analysis
-- Beginners looking for a clean LP example with real-world context
+- Manufacturing planners optimizing production schedules and ranking capacity investments
+- Operations researchers learning resource-constrained profit maximization and LP duality
+- Data scientists who want shadow prices and reduced costs without leaving the model
+- Anyone learning what "sensitivity analysis" means on a small, fully hand-checkable LP
## What you'll build
- A linear programming model that determines optimal production quantities per product
-- Resource capacity constraints tied to factory availability
-- Demand upper bounds on each product
-- Per-factory scenario analysis with independent optimization
+- Per-factory resource capacity constraints, captured as handles for marginal read-back
+- Demand upper bounds on each product (whose marginals surface as reduced costs)
+- A one-solve sensitivity report: capacity shadow prices, product reduced costs, and basis status, each joined back to its factory/product by entity key
## What's included
-- `factory_production.py` -- Main script defining the optimization model, constraints, and per-factory scenarios
+- `factory_production.py` -- Main script defining the optimization model, constraints, objective, and sensitivity read-back
- `data/factories.csv` -- Factory names and available resource-hours
- `data/products.csv` -- Products with factory assignment, production rate, profit, and demand cap
- `pyproject.toml` -- Python package configuration with dependencies
@@ -59,7 +65,7 @@ The template solves the problem independently per factory, demonstrating a scena
### Tools
- Python >= 3.10
-- RelationalAI Python SDK (`relationalai`) >= 1.0.14
+- RelationalAI Python SDK (`relationalai`) >= 1.9.0
## Quickstart
@@ -94,29 +100,41 @@ The template solves the problem independently per factory, demonstrating a scena
python factory_production.py
```
-6. Expected output:
+6. Expected output (model and solver display trimmed):
```text
- For factory: steel_factory
- Status: OPTIMAL, Profit: $192000.00
- Production plan:
- name value
- bands 6000.0
- coils 1400.0
-
- For factory: amazing_brewery
- Status: OPTIMAL, Profit: $6000.00
- Production plan:
- name value
- stouts 1000.0
- ales 1000.0
+ Baseline status: OPTIMAL, total profit: 200000.00
+
+ Production plan:
+ factory product quantity
+ amazing_brewery ales 2000.0
+ amazing_brewery stouts 1000.0
+ steel_factory bands 6000.0
+ steel_factory coils 1400.0
+
+ Factory capacity shadow prices (d profit / d hour):
+ factory avail shadow_price
+ amazing_brewery 30.0 -0.0
+ steel_factory 40.0 4200.0
+
+ Product reduced costs and basis status:
+ factory product reduced_cost basis_status
+ amazing_brewery ales 2.0 NONBASIC_AT_UPPER
+ amazing_brewery stouts 4.0 NONBASIC_AT_UPPER
+ steel_factory bands 4.0 NONBASIC_AT_UPPER
+ steel_factory coils -0.0 BASIC
+
+ Most profit-sensitive capacity: steel_factory (d profit / d hour = +4200.00)
==================================================
- Factory Production Summary
+ Factory Capacity Summary
==================================================
- steel_factory: OPTIMAL, profit=$192000.00
- amazing_brewery: OPTIMAL, profit=$6000.00
+ factory avail hours_used idle
+ amazing_brewery 30.0 25.0 5.0
+ steel_factory 40.0 40.0 0.0
```
+ Reading the result: `steel_factory` fills all 40 hours (it is the binding factory, so its capacity prices at **+4200/hour** -- the per-hour profit of the swing product `coils`, i.e. its 30/unit profit × 140 units/hour rate). `amazing_brewery` meets all demand in 25 of its 30 hours, so its capacity is **slack** and prices at **0** -- its bottleneck is demand, not capacity. Each of these demand-capped products (`bands`, `stouts`, `ales`) carries a positive reduced cost here: the profit you would gain per extra unit of demand allowed.
+
## Template structure
```text
@@ -148,49 +166,78 @@ Product.demand = Property(f"{Product} has {Integer:demand}")
### 2. Decision variables
-Each product gets a continuous variable bounded between 0 and its demand cap:
+Each product gets a continuous variable bounded between 0 and its demand cap. The demand cap is the variable's **upper bound** (not a separate constraint), so its marginal surfaces later as the variable's *reduced cost*:
```python
-problem.solve_for(
+quantity_var = problem.solve_for(
Product.x_quantity,
+ name=["qty", Product.factory.name, Product.name],
lower=0,
upper=Product.demand,
- name=Product.name,
- where=[this_product],
populate=False,
)
```
-### 3. Constraints and objective
+### 3. Capacity constraint and objective
-Resource usage at each factory must not exceed availability. The objective maximizes total profit:
+Each factory's total resource usage must not exceed its availability. The constraint is captured as a handle (`cap`), named per factory (a readable label), and declared with `keyed_by={"factory": Factory}`, so each instance's shadow price reads back through that **entity key** (`cap.factory`) rather than by parsing a name string. The objective maximizes total profit across all factories:
```python
-profit = sum(Product.profit * Product.x_quantity).where(this_product)
-problem.maximize(profit)
+cap = problem.satisfy(
+ model.require(
+ sum(Product.x_quantity / Product.rate)
+ .where(Product.factory == Factory)
+ .per(Factory)
+ <= Factory.avail
+ ),
+ name=["cap", Factory.name],
+ keyed_by={"factory": Factory},
+)
-problem.satisfy(model.require(
- sum(Product.x_quantity / Product.rate) <= Factory.avail
-).where(this_product, Factory.name(factory_name)))
+problem.maximize(sum(Product.profit * Product.x_quantity))
+problem.solve("highs", time_limit_sec=60, sensitivity=True)
```
-### 4. Per-factory scenario analysis
+### 4. Read the sensitivity marginals
-The template iterates over factories and solves an independent optimization for each, filtering products by their factory assignment. This allows comparison of optimal production plans across facilities.
+After a `sensitivity=True` solve, the marginals are attributes on the captured handles. A constraint carries the entity back-pointer declared with `keyed_by` (`cap.factory`) and a variable carries an automatic one to its product (`quantity_var.product`), so each marginal joins to that entity's own data by key -- no pandas, no name parsing:
+
+```python
+# Which factory's capacity to expand first?
+model.select(cap.factory.name, cap.factory.avail, cap.shadow_price).inspect()
+
+# Which products are pinned at their demand cap, and which is the swing product?
+model.select(
+ quantity_var.product.factory.name,
+ quantity_var.product.name,
+ quantity_var.reduced_cost,
+ quantity_var.basis_status,
+).inspect()
+```
+
+(`.inspect()` prints the rows for a quick look; the script materializes the same selects as DataFrames with `.to_df()` for its printed report and assertions.)
+
+Sensitivity marginals are exact for a linear program. They describe the rate of change at the current optimum -- the range over which that rate holds is not reported (there is no RHS/coefficient ranging) -- and a large, discrete change (adding a factory, removing a product) is a structural change best answered by re-solving.
## Customize this template
-- **Add more factories and products**: Extend the CSV files. The model automatically picks up new data and creates scenarios per factory.
-- **Shared resources across factories**: Remove the per-factory loop and solve a single global problem with cross-factory resource constraints.
-- **Multi-period planning**: Add a time dimension to model production across multiple periods with inventory carryover.
-- **Integer production**: Change the variable type from continuous to integer if products must be produced in whole units.
+- **Find the demand bottleneck**: Raise `amazing_brewery`'s demand caps in `products.csv`. Once its 30 hours bind, its capacity shadow price jumps from 0 to positive -- capacity becomes the bottleneck.
+- **Shift the swing product**: Lower `steel_factory`'s `avail` in `factories.csv`. `coils` (the basic, swing product) shrinks but stays the swing down to just above 30 hours, so the shadow price holds at 4200. At exactly 30 hours `coils` hits zero -- a degenerate breakpoint where the marginal is one-sided -- and below 30 hours `bands` becomes the swing and the price rises to 5000.
+- **Add more factories and products**: Extend the CSV files. The model and the per-factory marginals pick up new rows automatically.
+- **Integer production**: Change the variable type from continuous to integer if products must be produced in whole units. Note that sensitivity marginals are an LP concept -- they are reported only for continuous (LP/QP) problems, and are empty for integer models.
## Troubleshooting
-Problem is infeasible
+Shadow prices or reduced costs are all empty
+
+Sensitivity marginals are an LP/QP concept. They are populated only when the problem is continuous and solved with `sensitivity=True`. An integer (MIP) model returns no marginals -- keep the production variables continuous to see them.
+
+
+
+A factory's capacity shadow price is zero
-Check that factory availability is sufficient to produce at least some quantity of each product. If demand is high but resource-hours are too low, the bounded problem may still be feasible but produce small quantities.
+Usually that factory has idle resource-hours -- slack capacity, so an extra hour buys nothing, and its bottleneck is elsewhere (typically product demand). Confirm with the `idle` column in the capacity summary: a positive `idle` is genuine slack. (Less commonly, a *binding* capacity can also price at zero under degeneracy -- zero idle hours yet a zero price -- so read the `idle` column, not the price alone.)
@@ -206,7 +253,7 @@ Make sure you activated the virtual environment and ran `python -m pip install .
-Products missing from solution
+Products missing from the plan
-Products with zero quantity in the solution are not profitable enough to justify their resource usage. This is expected when resource availability is tight. Increase factory `avail` or reduce the production rate to see more products in the plan.
+A product with zero quantity is not profitable enough to justify its resource usage given tighter, more profitable competitors. Its reduced cost tells you how far its economics are from being worth producing. Increase factory `avail`, raise the product's profit, or lower its production rate to bring it into the plan.
diff --git a/v1/factory_production/factory_production.py b/v1/factory_production/factory_production.py
index 6aa04eb..d1d8139 100644
--- a/v1/factory_production/factory_production.py
+++ b/v1/factory_production/factory_production.py
@@ -1,25 +1,58 @@
"""Factory production (prescriptive optimization) template.
-This script demonstrates a linear optimization problem in RelationalAI:
+This script demonstrates a product-mix linear program in RelationalAI that
+maximizes total profit subject to per-factory resource capacity, then reads back
+the *marginals* a planner asks next:
-- Load sample CSVs describing factories and products with production rates and profit.
-- Model factories and products as *concepts* with typed properties.
-- Choose non-negative production quantities for each product, bounded by demand.
-- Constrain total resource usage per factory by available capacity.
-- Maximize total profit per factory via scenario analysis.
+- Load sample CSVs describing factories (resource availability) and the products
+ each factory makes (production rate, unit profit, demand cap).
+- Model them as *concepts* with typed properties.
+- Choose a non-negative production quantity per product, capped by its demand.
+- Limit each factory's total resource usage to its available hours.
+- Maximize total profit.
+
+A plain solve answers "what is the most profitable production plan?". Requesting
+``solve(sensitivity=True)`` ALSO answers the marginal questions -- in one solve,
+read straight off the variable / constraint objects (the same attribute style as
+``.name`` or ``.lower``):
+
+- **Shadow price** of a factory's capacity (``cap.shadow_price``): how much total
+ profit moves per extra hour of that factory. A capacity with idle hours prices at
+ zero (it is not the bottleneck); a positive price flags a binding capacity worth
+ expanding -- so this ranks which factory to expand first. (The implication runs one
+ way: slack => zero price, and a positive price => binding; a binding capacity *can*
+ still price at zero under degeneracy, though none does in this data.)
+- **Reduced cost** of a product (``quantity_var.reduced_cost``) and its **basis
+ status** (``quantity_var.basis_status``): a product held at its demand cap shows a
+ positive reduced cost here (the extra profit per unit of demand allowed); the swing
+ product that sets the binding factory's marginal price is BASIC at ~0.
+
+Sign convention (the mirror image of a minimize-cost model): with a MAXIMIZE
+objective, a binding ``<=`` capacity prices >= 0 and a product at its demand
+*upper* bound has reduced cost >= 0. (Contrast the ``supplier_reliability``
+template, which minimizes cost: there a binding ``<=`` capacity prices <= 0.) Note
+the two sensitivity objects come from two different modeling choices: capacity is a
+*constraint* (its marginal is a shadow price), while the demand cap is a variable
+*upper bound* (its marginal is a reduced cost).
+
+A constraint declared with ``keyed_by={"factory": Factory}`` carries an entity
+back-pointer to what it grounds over (``cap.factory``), so a marginal joins to that
+entity's own data by ENTITY KEY (``cap.factory.avail``) -- never by parsing the
+constraint name string. (A variable's back-pointer, such as ``quantity_var.product``,
+is derived automatically from its field names; a constraint's is declared.)
Run:
`python factory_production.py`
Output:
- Prints per-factory termination status, profit, and a production plan table,
- followed by a factory production summary.
+ Prints the production plan, each factory's capacity shadow price, each product's
+ reduced cost and basis status, and a per-factory capacity-utilization summary.
"""
from pathlib import Path
from pandas import read_csv
-from relationalai.semantics import Float, Integer, Model, String, sum
+from relationalai.semantics import Float, Integer, Model, String, std, sum
from relationalai.semantics.reasoners.prescriptive import Problem
# --------------------------------------------------
@@ -35,7 +68,7 @@
model = Model("factory_production")
Concept, Property = model.Concept, model.Property
-# Factory concept: factories with total resource availability.
+# Factory concept: factories with total resource availability (hours).
Factory = Concept("Factory", identify_by={"name": String})
Factory.avail = Property(f"{Factory} has {Float:avail}")
@@ -43,7 +76,7 @@
factory_csv = read_csv(DATA_DIR / "factories.csv")
model.define(Factory.new(model.data(factory_csv).to_schema()))
-# Product concept: products with production rate, profit, demand cap, and parent factory.
+# Product concept: products with production rate, unit profit, demand cap, and parent factory.
Product = Concept("Product", identify_by={"name": String, "factory_name": String})
Product.factory = Property(f"{Product} is produced by {Factory}")
Product.rate = Property(f"{Product} has {Float:rate}")
@@ -64,80 +97,219 @@
Factory.name(product_data.factory_name),
)
+# Guard the reference data. Every sensitivity assertion below is gated by a hard-coded
+# factory/product name, so if data/ is edited those asserts could silently pass on zero
+# rows. Fail loudly here instead, with a clear message, the moment the data drifts.
+EXPECTED_FACTORIES = {"steel_factory", "amazing_brewery"}
+EXPECTED_PRODUCTS = {"bands", "coils", "stouts", "ales"}
+assert set(factory_csv["name"]) == EXPECTED_FACTORIES, (
+ f"factories.csv changed: {set(factory_csv['name'])} != {EXPECTED_FACTORIES}"
+)
+assert set(product_csv["name"]) == EXPECTED_PRODUCTS, (
+ f"products.csv changed: {set(product_csv['name'])} != {EXPECTED_PRODUCTS}"
+)
+# Product names are unique across factories, so the plan is keyed by name alone below.
+assert product_csv["name"].is_unique, (
+ f"product names are not unique across factories: {list(product_csv['name'])}"
+)
+
# --------------------------------------------------
# Model the decision problem
# --------------------------------------------------
-# Variable: quantity[product] = amount produced (bounded by demand)
+# Variable: quantity[product] = amount produced (0 .. demand cap).
Product.x_quantity = Property(f"{Product} has {Float:quantity}")
-# Scenarios: solve independently per factory
-SCENARIO_PARAM = "factory_name"
-SCENARIO_VALUES = list(factory_csv["name"])
-
# --------------------------------------------------
-# Solve and check solution
+# Baseline solve with sensitivity analysis
# --------------------------------------------------
+# One product-mix LP over ALL factories. They share no resources, so the joint
+# optimum is just each factory solved independently -- but a single solve lets us
+# read every factory's marginals side by side off the captured handles.
-scenario_results = []
+problem = Problem(model, Float)
-for factory_name in SCENARIO_VALUES:
- print(f"\nFor factory: {factory_name}")
+# Variable: production quantity per product. The demand cap is the variable's UPPER
+# BOUND (not a separate constraint), so its marginal surfaces as the variable's
+# reduced cost below. populate=False keeps the solution read explicit via values().
+quantity_var = problem.solve_for(
+ Product.x_quantity,
+ name=["qty", Product.factory.name, Product.name],
+ lower=0,
+ upper=Product.demand,
+ populate=False,
+)
+
+# Constraint: each factory's total resource usage <= its available hours. Captured as
+# a handle; ``keyed_by`` declares each instance's ENTITY KEY, so its shadow price reads
+# back through that key (cap.factory), never by parsing the constraint name string; the
+# per-factory name is a readable label.
+cap = problem.satisfy(
+ model.require(
+ sum(Product.x_quantity / Product.rate)
+ .where(Product.factory == Factory)
+ .per(Factory)
+ <= Factory.avail
+ ),
+ name=["cap", Factory.name],
+ keyed_by={"factory": Factory},
+)
- # Restrict to products of this factory
- this_product = Product.factory.name(factory_name)
+# Objective: maximize total profit across all factories.
+problem.maximize(sum(Product.profit * Product.x_quantity))
- problem = Problem(model, Float)
+problem.display()
+problem.solve("highs", time_limit_sec=60, sensitivity=True)
+si = problem.solve_info()
+si.display()
+
+assert si.termination_status == "OPTIMAL"
+assert si.sensitivity is True
+# Optimum: steel_factory fills all 40 hrs (bands to its 6000 cap = 30 hrs, coils takes
+# the last 10 hrs = 1400 units); amazing_brewery makes both products to their demand
+# caps in 25 of its 30 hrs. Profit = 192000 + 8000 = 200000.
+assert si.objective_value is not None and abs(si.objective_value - 200000) < 0.01, (
+ f"total profit changed: expected 200000, got {si.objective_value}"
+)
+
+print(f"\nBaseline status: {si.termination_status}, total profit: {si.objective_value:.2f}")
+
+# --- The solved production plan -------------------------------------------------
+# Read every product's solved quantity once via values(0, ref) on the variable (no
+# populate). Columns are aliased so the printed headers read as factory / product /
+# quantity; this one frame feeds the plan table, the plan assertion, and the capacity
+# summary below.
+plan_ref = Float.ref()
+plan_df = (
+ model.select(
+ quantity_var.product.factory.name.alias("factory"),
+ quantity_var.product.name.alias("product"),
+ quantity_var.product.factory.avail.alias("avail"),
+ quantity_var.product.rate.alias("rate"),
+ plan_ref.alias("quantity"),
+ )
+ .where(quantity_var.values(0, plan_ref))
+ .to_df()
+ .sort_values(["factory", "product"], ignore_index=True)
+)
+print("\nProduction plan:")
+produced = plan_df.loc[plan_df["quantity"] > 1e-6, ["factory", "product", "quantity"]]
+print(produced.to_string(index=False))
- # Variable: production quantity per product, bounded by demand
- quantity_var = problem.solve_for(
- Product.x_quantity,
- lower=0,
- upper=Product.demand,
- name=Product.name,
- where=[this_product],
- populate=False,
+# Pin the whole plan, not just the objective scalar: in general a matching objective is
+# necessary but not sufficient, because a different product mix could reach the same
+# profit. This LP's optimum happens to be unique (each factory's hours pin its plan), so
+# pinning the quantities both guards against a regression and documents that uniqueness.
+# Product names are unique across factories (asserted above), so the per-name lookup is a
+# single row; tolerance is tight because the vertices here are exactly integral.
+EXPECTED_PLAN = {"bands": 6000.0, "coils": 1400.0, "stouts": 1000.0, "ales": 2000.0}
+for product_name, expected_qty in EXPECTED_PLAN.items():
+ actual_qty = plan_df.loc[plan_df["product"] == product_name, "quantity"].sum()
+ assert abs(actual_qty - expected_qty) < 0.01, (
+ f"{product_name}: expected {expected_qty}, got {actual_qty}"
)
- # Objective: maximize profit = sum(quantity * profit_per_unit)
- profit = sum(Product.profit * Product.x_quantity).where(this_product)
- problem.maximize(profit)
-
- # Constraint: total resource usage <= factory availability
- problem.satisfy(model.require(
- sum(Product.x_quantity / Product.rate) <= Factory.avail
- ).where(this_product, Factory.name(factory_name)))
-
- problem.display()
- problem.solve("highs", time_limit_sec=60)
- si = problem.solve_info()
- si.display()
-
- scenario_results.append(
- {
- "factory": factory_name,
- "status": str(si.termination_status),
- "profit": si.objective_value,
- }
+# --- Capacity shadow prices: which factory to expand first ----------------------
+# A constraint carries its declared entity key as a back-pointer (cap.factory), so a
+# shadow price joins to that factory's own data by KEY -- no name parsing, no pandas join.
+cap_df = (
+ model.select(
+ cap.factory.name.alias("factory"),
+ cap.factory.avail.alias("avail"),
+ cap.shadow_price.alias("shadow_price"),
)
- if si.termination_status != "OPTIMAL":
- print(f" Status: {si.termination_status} — skipping results")
- continue
- print(f" Status: {si.termination_status}, Profit: ${si.objective_value:.2f}")
-
- # Extract solution via Variable.values() — populate=False avoids overwriting between scenarios.
- value_ref = Float.ref()
- produced = model.select(
+ .to_df()
+ .sort_values("factory", ignore_index=True)
+)
+print("\nFactory capacity shadow prices (d profit / d hour):")
+print(cap_df.to_string(index=False))
+# Maximize + a <= capacity => shadow_price >= 0: an extra hour can only help. The
+# implication runs one way: a factory with idle hours prices at 0 (slack -- its
+# bottleneck is demand, not capacity), and a positive price marks a binding capacity.
+# steel_factory is full (binding) and carries the marginal value of an hour; every other
+# factory has spare hours (slack) and prices at 0.
+model.where(cap.factory.name == "steel_factory").require(cap.shadow_price > 1e-6)
+model.where(cap.factory.name != "steel_factory").require(
+ std.math.abs(cap.shadow_price) < 1e-6
+)
+
+# --- Reduced costs: which products are demand-capped? ---------------------------
+# Each marginal reads straight off the variable; the variable's back-pointer
+# (quantity_var.product) joins it to the product's factory by ENTITY KEY.
+rc_df = (
+ model.select(
+ quantity_var.product.factory.name.alias("factory"),
quantity_var.product.name.alias("product"),
- value_ref.alias("quantity"),
- ).where(quantity_var.values(0, value_ref), value_ref > 0.001).to_df()
- print(f" Production plan:\n{produced.to_string(index=False)}")
+ quantity_var.reduced_cost.alias("reduced_cost"),
+ quantity_var.basis_status.alias("basis_status"),
+ )
+ .to_df()
+ .sort_values(["factory", "product"], ignore_index=True)
+)
+print("\nProduct reduced costs and basis status:")
+print(rc_df.to_string(index=False))
+# A product pinned at its demand cap is NONBASIC_AT_UPPER with reduced_cost >= 0 (the
+# extra profit from one more unit of allowed demand); the swing product that sets a
+# binding factory's marginal price is BASIC at ~0. Here bands, stouts and ales sit at
+# their demand caps; coils is the swing product in steel_factory. Assert BOTH halves of
+# the lesson -- the reduced cost AND the basis status -- so a regression in either is loud.
+for capped in ("bands", "stouts", "ales"):
+ model.where(quantity_var.product.name == capped).require(
+ quantity_var.reduced_cost > 1e-6
+ )
+ model.where(quantity_var.product.name == capped).require(
+ quantity_var.basis_status == "NONBASIC_AT_UPPER"
+ )
+# coils is basic (interior, between 0 and its demand cap): reduced cost ~0, status BASIC.
+# This coils read runs AFTER the requires above, and a query is what validates pending
+# relational requires -- so the same select that fetches coils also checks them. Zero-
+# checks use a HiGHS float tolerance (1e-4); the requires use 1e-6 (both are exact-0 here).
+coils_df = (
+ model.select(
+ quantity_var.reduced_cost.alias("reduced_cost"),
+ quantity_var.basis_status.alias("basis_status"),
+ )
+ .where(quantity_var.product.name == "coils")
+ .to_df()
+)
+assert (coils_df["reduced_cost"].abs() < 1e-4).all()
+assert (coils_df["basis_status"] == "BASIC").all()
+
+# --- Acting on it: which factory's capacity is the bottleneck? ------------------
+# With a maximize objective every capacity shadow price is >= 0, so the largest one is
+# the factory whose marginal hour earns the most -- the capacity to expand first.
+bottleneck = max(zip(cap_df["factory"], cap_df["shadow_price"]), key=lambda fp: fp[1])
+print(
+ f"\nMost profit-sensitive capacity: {bottleneck[0]} (d profit / d hour = {bottleneck[1]:+.2f})"
+)
-# Summary
+# --------------------------------------------------
+# Summary -- capacity utilization per factory
+# --------------------------------------------------
+# Ties the shadow prices above back to the plan: in this data the binding factory has
+# zero idle hours and a positive shadow price, while idle hours mean slack and a zero
+# price. Only idle => zero-price holds in general; a binding capacity can still price at
+# zero under degeneracy, so read the prices, not just the idle column.
+plan_df["hours"] = plan_df["quantity"] / plan_df["rate"]
+util = (
+ plan_df.groupby("factory")
+ .agg(avail=("avail", "first"), hours_used=("hours", "sum"))
+ .reset_index()
+)
+util["idle"] = util["avail"] - util["hours_used"]
print("\n" + "=" * 50)
-print("Factory Production Summary")
+print("Factory Capacity Summary")
print("=" * 50)
-for result in scenario_results:
- profit = result["profit"]
- profit_str = f"${profit:.2f}" if profit is not None else "N/A"
- print(f" {result['factory']}: {result['status']}, profit={profit_str}")
+print(util.to_string(index=False))
+
+# --------------------------------------------------
+# Customize
+# --------------------------------------------------
+# Try editing data/ and re-running:
+# - Raise amazing_brewery's demand caps (products.csv) until its 30 hrs bind: its
+# capacity shadow price jumps from 0 to positive once capacity becomes the bottleneck.
+# - Lower steel_factory's avail (factories.csv): coils (the swing product) shrinks but
+# stays the swing down to just above 30 hrs, so the shadow price holds at 4200. At
+# exactly 30 hrs coils hits zero -- a degenerate breakpoint where the marginal is
+# one-sided -- and below 30 hrs bands can no longer fill its 6000 demand, so bands
+# becomes the swing and the price rises to 5000 (its own profit-per-hour, 25 x 200).
diff --git a/v1/factory_production/pyproject.toml b/v1/factory_production/pyproject.toml
index f05771b..a5518b5 100644
--- a/v1/factory_production/pyproject.toml
+++ b/v1/factory_production/pyproject.toml
@@ -9,7 +9,7 @@ description = "RelationalAI template: factory_production (PyRel v1)"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
- "relationalai==1.0.14",
+ "relationalai==1.9.0",
"pandas",
]
diff --git a/v1/supplier_reliability/README.md b/v1/supplier_reliability/README.md
index 8359824..f774f11 100644
--- a/v1/supplier_reliability/README.md
+++ b/v1/supplier_reliability/README.md
@@ -1,14 +1,15 @@
---
title: "Supplier Reliability"
-description: "Select suppliers to meet product demand while balancing cost and reliability."
+description: "Select suppliers to meet product demand at minimum cost, with sensitivity marginals and supplier-disruption scenario analysis."
featured: false
experience_level: intermediate
-industry: "Supply Chain"
+industry: "Supply Chain & Logistics"
reasoning_types:
- Prescriptive
tags:
- Supplier Selection
- Scenario Analysis
+ - Sensitivity Analysis
- Cost Optimization
---
@@ -16,11 +17,17 @@ tags:
## What this template is for
-Procurement teams must choose which suppliers to source from when multiple options exist for each product. Each supplier has different pricing, capacity limits, and reliability scores. The challenge is to meet all product demand at minimum cost without exceeding any supplier's capacity.
+Procurement teams must choose which suppliers to source from when multiple options exist for each product. Each supplier has different pricing and capacity limits (plus a reliability score, carried as extension data -- not priced into the objective, and not what drives the disruption scenarios below). The challenge is to meet all product demand at minimum cost without exceeding any supplier's capacity.
This template uses **Prescriptive** reasoning to formulate the supplier selection problem as a linear program. It determines the optimal order quantities across supply options, ensuring that every product's demand is met and no supplier is overloaded. The solver finds the cost-minimizing allocation automatically.
-The template also demonstrates scenario analysis by re-solving the problem with specific suppliers excluded. This lets you evaluate supply chain resilience -- what happens to cost and feasibility if a key supplier becomes unavailable?
+A plain solve answers *"what is the cheapest sourcing plan?"*. This template requests **sensitivity analysis** (`solve(sensitivity=True)`) on the baseline, which ALSO answers the *marginal* questions a planner asks next -- in the same solve:
+
+- **Which supplier capacity is the bottleneck?** The *shadow price* of each capacity constraint (`cap.shadow_price`) is how much total cost moves per unit of that supplier's capacity. A capacity with room to spare prices at zero; a nonzero price marks a binding bottleneck.
+- **What does one more unit of demand cost?** The shadow price of each demand constraint (`meet.shadow_price`) is the marginal cost to serve one more unit of that product.
+- **Which supply lanes are priced out?** A lane's *reduced cost* (`qty_var.reduced_cost`) and *basis status* (`qty_var.basis_status`) show which options are unused and how far their cost must fall before they enter the plan.
+
+Finally, the template demonstrates **scenario analysis** by re-solving the problem with specific suppliers fully excluded. This is a *finite, structural* change -- what happens to cost and feasibility if a key supplier becomes unavailable? -- that the local marginals contextualize but do not by themselves predict.
## Who this is for
@@ -32,6 +39,7 @@ The template also demonstrates scenario analysis by re-solving the problem with
- A linear programming model that allocates order quantities across suppliers and products
- Capacity and demand satisfaction constraints
+- A baseline solve with sensitivity analysis: capacity and demand shadow prices, plus lane reduced costs and basis status, read back by entity key
- A scenario loop that excludes suppliers one at a time to assess supply chain risk
- A summary comparing cost and feasibility across scenarios
@@ -51,7 +59,7 @@ The template also demonstrates scenario analysis by re-solving the problem with
### Tools
- Python >= 3.10
-- RelationalAI Python SDK (`relationalai`) >= 1.0.14
+- RelationalAI Python SDK (`relationalai`) >= 1.9.0
## Quickstart
@@ -86,34 +94,64 @@ The template also demonstrates scenario analysis by re-solving the problem with
python supplier_reliability.py
```
-6. Expected output:
+6. Expected output (model and solver display trimmed; marginal tables are read back by entity key):
```text
- Running scenario: baseline
- Status: OPTIMAL, Objective: 4850.0
-
- Orders:
- qty_SupplierB_Gadget 150.0
- qty_SupplierC_Component 200.0
- qty_SupplierC_Gadget 100.0
- qty_SupplierC_Widget 300.0
+ Baseline status: OPTIMAL, objective: 4850.00
+
+ Baseline orders:
+ supplier product quantity
+ SupplierB Component 150.0
+ SupplierC Component 50.0
+ SupplierC Gadget 250.0
+ SupplierC Widget 300.0
+
+ Lane reduced costs and basis status:
+ supplier product reduced_cost basis_status
+ SupplierA Gadget 3.0 NONBASIC_AT_LOWER
+ SupplierA Widget 2.0 NONBASIC_AT_LOWER
+ SupplierB Component 0.0 BASIC
+ SupplierB Gadget 0.0 NONBASIC_AT_LOWER
+ SupplierB Widget 0.0 NONBASIC_AT_LOWER
+ SupplierC Component 0.0 BASIC
+ SupplierC Gadget 0.0 BASIC
+ SupplierC Widget 0.0 BASIC
+ SupplierD Component 2.0 NONBASIC_AT_LOWER
+ SupplierD Gadget 2.0 NONBASIC_AT_LOWER
+
+ Supplier capacity shadow prices (d cost / d capacity):
+ supplier capacity shadow_price
+ SupplierA 500 0.0
+ SupplierB 400 0.0
+ SupplierC 600 -2.0
+ SupplierD 350 0.0
+
+ Product demand shadow prices (d cost / d demand):
+ product demand shadow_price
+ Component 200 7.0
+ Gadget 250 9.0
+ Widget 300 8.0
+
+ Most cost-sensitive capacity: SupplierC (d cost / d capacity = -2.00)
Running scenario: without_SupplierC
Status: OPTIMAL, Objective: 6750.0
Orders:
- qty_SupplierB_Component 100.0
- qty_SupplierB_Widget 300.0
- qty_SupplierD_Component 100.0
- qty_SupplierD_Gadget 250.0
+ supplier product quantity
+ SupplierA Widget 300.0
+ SupplierB Component 200.0
+ SupplierB Gadget 200.0
+ SupplierD Gadget 50.0
Running scenario: without_SupplierB
Status: OPTIMAL, Objective: 5150.0
Orders:
- qty_SupplierA_Widget 150.0
- qty_SupplierC_Component 200.0
- qty_SupplierC_Gadget 250.0
- qty_SupplierC_Widget 150.0
+ supplier product quantity
+ SupplierC Component 200.0
+ SupplierC Gadget 100.0
+ SupplierC Widget 300.0
+ SupplierD Gadget 150.0
==================================================
Scenario Analysis Summary
@@ -123,10 +161,25 @@ The template also demonstrates scenario analysis by re-solving the problem with
without_SupplierB: OPTIMAL, obj=5150.00
```
- The baseline relies heavily on SupplierC (cheapest). Removing SupplierC
- increases cost by 39% ($4,850 to $6,750) as demand shifts to more expensive
- SupplierB and SupplierD. Removing SupplierB has less impact (+6%) since
- SupplierC absorbs most of the displaced volume.
+ **Reading the marginals.** SupplierC is the cheapest source for every product, so
+ it fills its 600-unit capacity and is the only **binding** capacity -- its shadow
+ price of `-2.0` means each extra unit of SupplierC capacity would lower total cost
+ by $2. Every other capacity has room to spare and prices at `0`. The demand shadow
+ prices (`7`, `9`, `8` for Component, Gadget, Widget) are the marginal cost of one
+ more unit of each product.
+ SupplierA's and SupplierD's lanes are **priced out** (positive reduced cost); note
+ SupplierB's unused lanes price at `~0` because each is exactly $2 above SupplierC --
+ an alternate-optimum tie, which is why the script asserts only that *used* lanes
+ have ~0 reduced cost, never that *every* unused lane is strictly positive. The
+ exact order quantities (and the matching basis statuses) above are one of several
+ cost-equal optima -- a different HiGHS build may land on another vertex with the
+ same $4,850 objective and the same shadow prices.
+
+ **Scenario analysis.** Removing SupplierC entirely increases cost by 39% ($4,850 to
+ $6,750) as demand shifts to the more expensive SupplierA, SupplierB, and SupplierD -- consistent
+ with SupplierC's high marginal value, though the duals (local marginals) do not by
+ themselves predict the full impact of removing all 600 units. Removing SupplierB has
+ less impact (+6%) since SupplierC absorbs most of the displaced volume.
## Template structure
```text
@@ -151,7 +204,7 @@ Supplier = Concept("Supplier", identify_by={"id": Integer})
Supplier.name = Property(f"{Supplier} has {String:name}")
Supplier.reliability = Property(f"{Supplier} has {Float:reliability}")
Supplier.capacity = Property(f"{Supplier} has {Integer:capacity}")
-supplier_csv = read_csv(data_dir / "suppliers.csv")
+supplier_csv = read_csv(DATA_DIR / "suppliers.csv")
model.define(Supplier.new(model.data(supplier_csv).to_schema()))
```
@@ -177,37 +230,72 @@ model.define(SupplyOrder.new(option=SupplyOption))
### 3. Add constraints and objective
-Capacity and demand constraints ensure feasibility, while the objective minimizes total procurement cost:
+Capacity and demand constraints ensure feasibility, while the objective minimizes total procurement cost. Each constraint is **captured as a handle**, **named per entity** (a readable label), and declared with **`keyed_by`** -- the entity key its marginal reads back through after the solve:
+
+```python
+cap = baseline.satisfy(
+ model.require(
+ sum(SupplyOrder.x_quantity).where(SupplyOrder.supplier == Supplier).per(Supplier) <= Supplier.capacity
+ ),
+ name=["cap", Supplier.name],
+ keyed_by={"supplier": Supplier},
+)
+meet = baseline.satisfy(
+ model.require(
+ sum(SupplyOrder.x_quantity).where(SupplyOrder.product == Product).per(Product) >= Product.demand
+ ),
+ name=["demand", Product.name],
+ keyed_by={"product": Product},
+)
+baseline.minimize(sum(SupplyOrder.x_quantity * SupplyOrder.cost_per_unit))
+```
+
+### 4. Request sensitivity and read the marginals
+
+Solve the baseline with `sensitivity=True`, then read each marginal straight off the variable or constraint object -- the same attribute style as `.name`. A constraint declared with `keyed_by` carries an **entity back-pointer** (`cap.supplier`, `meet.product`), mirroring the variable's automatic back-pointer (`qty_var.supplyorder`), so a marginal joins to that entity's own data by KEY -- no name parsing, no pandas:
```python
-problem.satisfy(model.require(
- sum(SupplyOrder.x_quantity).where(SupplyOrder.supplier == Supplier).per(Supplier) <= Supplier.capacity
-))
-problem.satisfy(model.require(
- sum(SupplyOrder.x_quantity).where(SupplyOrder.product == Product).per(Product) >= Product.demand
-))
-problem.minimize(sum(SupplyOrder.x_quantity * SupplyOrder.cost_per_unit))
+baseline.solve("highs", time_limit_sec=60, sensitivity=True)
+
+# Capacity shadow prices, joined to each supplier's capacity by key:
+model.select(cap.supplier.name, cap.supplier.capacity, cap.shadow_price).inspect()
+# Demand shadow prices, joined to each product's demand by key:
+model.select(meet.product.name, meet.product.demand, meet.shadow_price).inspect()
+# Lane reduced costs and basis status, joined to supplier / product by key:
+model.select(
+ qty_var.supplyorder.supplier.name, qty_var.supplyorder.product.name,
+ qty_var.reduced_cost, qty_var.basis_status,
+).inspect()
```
-### 4. Scenario analysis
+(`.inspect()` prints the rows for a quick look; the script materializes the same selects as DataFrames with `.to_df()` for its printed report and assertions.)
+
+The economics are also stated as integrity constraints joined by the same keys -- but only the always-true directions of complementary slackness (a lane in use prices at ~0; SupplierA's lanes are priced out). The converse "every unused lane has a positive reduced cost" is **not** asserted, because SupplierB's lanes tie SupplierC at the margin (alternate optima).
+
+> [!NOTE]
+> Sensitivity analysis returns marginals only for LP/QP models (linear constraints with a linear or quadratic objective). For mixed-integer models the duals are empty -- use scenario analysis instead. The marginal reads must happen on the **baseline** Problem, before the scenario loop rebuilds a fresh Problem.
+
+### 5. Scenario analysis
-The script loops over supplier exclusion scenarios, setting excluded supplier quantities to zero:
+Each disruption scenario is a separate Problem that excludes one supplier with a `where=` filter on the decision variable -- a finite, structural change the marginals contextualize but do not by themselves predict:
```python
-for excluded_supplier in SCENARIO_VALUES:
+for excluded in ["SupplierC", "SupplierB"]:
problem = Problem(model, Float)
- # ... define variables and constraints ...
- if excluded_supplier is not None:
- exclude = model.require(SupplyOrder.x_quantity == 0).where(
- SupplyOrder.supplier.name == excluded_supplier
- )
- problem.satisfy(exclude)
+ qty_scn = problem.solve_for(
+ SupplyOrder.x_quantity,
+ name=["qty", SupplyOrder.supplier.name, SupplyOrder.product.name],
+ lower=0,
+ where=[SupplyOrder.supplier.name != excluded],
+ populate=False,
+ )
+ # ... re-add capacity / demand constraints and the objective ...
problem.solve("highs", time_limit_sec=60)
```
## Customize this template
-- **Add a reliability penalty** to the objective function, weighting cost against supplier reliability scores to find the Pareto-optimal balance.
+- **Add a reliability penalty** to the objective function, weighting cost against supplier reliability scores. One weighting yields a single trade-off point; sweep the weight to trace the cost-vs-reliability frontier.
- **Expand the scenario analysis** to exclude combinations of suppliers or simulate capacity reductions.
- **Add minimum order quantities** by setting lower bounds on the decision variables for active supply options.
- **Introduce transportation costs** by adding a distance or shipping cost dimension to supply options.
diff --git a/v1/supplier_reliability/pyproject.toml b/v1/supplier_reliability/pyproject.toml
index 4e0d4c2..1dd217e 100644
--- a/v1/supplier_reliability/pyproject.toml
+++ b/v1/supplier_reliability/pyproject.toml
@@ -9,7 +9,7 @@ description = "RelationalAI template: supplier_reliability (PyRel v1)"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
- "relationalai==1.0.14",
+ "relationalai==1.9.0",
"pandas",
]
diff --git a/v1/supplier_reliability/supplier_reliability.py b/v1/supplier_reliability/supplier_reliability.py
index 3a556d8..625de34 100644
--- a/v1/supplier_reliability/supplier_reliability.py
+++ b/v1/supplier_reliability/supplier_reliability.py
@@ -1,33 +1,59 @@
"""Supplier Reliability (prescriptive optimization) template.
This script demonstrates a sourcing optimization model in RelationalAI that
-balances cost and supplier reliability:
+minimizes total sourcing cost subject to supplier capacity and product demand, then
+reads back the *marginals* a planner asks next (and stress-tests supplier reliability
+with disruption scenarios):
- Load sample CSVs describing suppliers, products, and supplier-product supply options.
- Model those entities as *concepts* with typed properties.
- Choose non-negative order quantities for each supply option.
- Enforce supplier capacity limits and product demand satisfaction.
-- Minimize total cost, with disruption scenario analysis that optionally excludes
- a supplier using the loop + where= filter pattern.
+- Minimize total cost.
+
+A plain solve answers "what is the cheapest sourcing plan?". Requesting
+``solve(sensitivity=True)`` on the baseline ALSO answers the marginal questions --
+in one solve, with the answers read straight off the variable / constraint objects
+(the same attribute style as ``.name`` or ``.lower``):
+
+- **Shadow price** of a supplier's capacity (``cap.shadow_price``): how much total
+ cost moves per unit of capacity. A capacity with room to spare prices at zero; a
+ nonzero price marks a binding bottleneck -- so this ranks which supplier to expand
+ first. (One way only: slack => zero price, nonzero price => binding.)
+- **Shadow price** of a product's demand (``meet.shadow_price``): the marginal cost
+ to serve one more unit of that product.
+- **Reduced cost** of a supply lane (``qty_var.reduced_cost``) and its **basis
+ status** (``qty_var.basis_status``): which lanes are priced out versus in use.
+
+A constraint declared with ``keyed_by={"supplier": Supplier}`` carries an entity
+back-pointer to what it grounds over (``cap.supplier`` / ``meet.product``), so a
+marginal joins to that entity's own data by ENTITY KEY (``cap.supplier.capacity``)
+-- never by parsing the constraint name string. (A variable's back-pointer, such as
+``qty_var.supplyorder``, is derived automatically from its field names; a
+constraint's is declared.)
Modeling approach:
-- Each disruption scenario (baseline, exclude SupplierC, exclude SupplierB) is solved
- as a separate Problem instance with a where= filter on solve_for.
-- Entity exclusion is handled cleanly via where= filter — no constraint injection needed.
-- Results collected per iteration and compared post-loop.
+- Phase A: one baseline Problem solved with ``sensitivity=True``; the marginals are
+ read off the captured variable / constraint handles. (These reads live on the
+ baseline Problem, OUTSIDE the scenario loop -- a handle captured in the loop would
+ carry the last exclusion scenario's marginals instead.)
+- Phase B: disruption scenarios (exclude SupplierC, exclude SupplierB), each a
+ separate Problem with a where= filter -- a FINITE structural change the local
+ marginals contextualize but do not by themselves predict.
Run:
`python supplier_reliability.py`
Output:
- Prints the solver termination status and an order plan per scenario, then a
- scenario summary table with termination status and objective value.
+ Prints the baseline plan with its capacity / demand shadow prices, lane reduced
+ costs and basis status, then an order plan per disruption scenario and a summary
+ table with termination status and objective value.
"""
from pathlib import Path
from pandas import read_csv
-from relationalai.semantics import Float, Integer, Model, String, sum
+from relationalai.semantics import Float, Integer, Model, String, std, sum
from relationalai.semantics.reasoners.prescriptive import Problem
# --------------------------------------------------
@@ -43,7 +69,9 @@
model = Model("supplier_reliability")
Concept, Property = model.Concept, model.Property
-# Concept: suppliers with reliability scores and capacity
+# Concept: suppliers with capacity and a reliability score. (reliability is carried as
+# data only -- it is NOT priced into the cost objective and does NOT drive the hard-coded
+# Phase B exclusions below; it is here for you to extend, see "Customize".)
Supplier = Concept("Supplier", identify_by={"id": Integer})
Supplier.name = Property(f"{Supplier} has {String:name}")
Supplier.reliability = Property(f"{Supplier} has {Float:reliability}")
@@ -58,7 +86,7 @@
product_csv = read_csv(DATA_DIR / "products.csv")
model.define(Product.new(model.data(product_csv).to_schema()))
-# Relationship: supply options linking suppliers to products
+# Concept: supply options (reified supplier-product links) with a per-unit cost
SupplyOption = Concept("SupplyOption", identify_by={"id": Integer})
SupplyOption.supplier = Property(f"{SupplyOption} from {Supplier}", short_name="supplier")
SupplyOption.product = Property(f"{SupplyOption} for {Product}", short_name="product")
@@ -67,8 +95,12 @@
options_csv = read_csv(DATA_DIR / "supply_options.csv")
options_data = model.data(options_csv)
model.define(
- so := SupplyOption.new(id=options_data.id, supplier=Supplier, product=Product,
- cost_per_unit=options_data.cost_per_unit)
+ SupplyOption.new(
+ id=options_data.id,
+ supplier=Supplier,
+ product=Product,
+ cost_per_unit=options_data.cost_per_unit,
+ )
).where(Supplier.id == options_data.supplier_id, Product.id == options_data.product_id)
# --------------------------------------------------
@@ -92,68 +124,278 @@
model.define(SupplyOrder.cost_per_unit(SupplyOption.cost_per_unit)).where(SupplyOrder.option(SupplyOption))
# --------------------------------------------------
-# Solve each disruption scenario (loop + where= filter)
+# Phase A -- baseline solve with sensitivity analysis
# --------------------------------------------------
+# The marginal reads MUST live on this single baseline Problem, built OUTSIDE the
+# scenario loop below: the loop rebuilds a fresh Problem each iteration, so a handle
+# captured in the loop would carry the LAST exclusion scenario's marginals.
+
+baseline = Problem(model, Float)
+
+# Variable: order quantity per supply lane (continuous, non-negative).
+qty_var = baseline.solve_for(
+ SupplyOrder.x_quantity,
+ name=["qty", SupplyOrder.supplier.name, SupplyOrder.product.name],
+ lower=0,
+ populate=False,
+)
+
+# Constraints, captured as handles. ``keyed_by`` declares each instance's ENTITY KEY,
+# so its marginal reads back through that key (cap.supplier / meet.product), never by
+# parsing the constraint name string; the per-entity name is a readable label.
+cap = baseline.satisfy(
+ model.require(
+ sum(SupplyOrder.x_quantity)
+ .where(SupplyOrder.supplier == Supplier)
+ .per(Supplier)
+ <= Supplier.capacity
+ ),
+ name=["cap", Supplier.name],
+ keyed_by={"supplier": Supplier},
+)
+meet = baseline.satisfy(
+ model.require(
+ sum(SupplyOrder.x_quantity).where(SupplyOrder.product == Product).per(Product)
+ >= Product.demand
+ ),
+ name=["demand", Product.name],
+ keyed_by={"product": Product},
+)
+
+# Objective: minimize total sourcing cost.
+baseline.minimize(sum(SupplyOrder.x_quantity * SupplyOrder.cost_per_unit))
+
+baseline.display()
+baseline.solve("highs", time_limit_sec=60, sensitivity=True)
+si = baseline.solve_info()
+si.display()
+
+assert si.termination_status == "OPTIMAL"
+assert si.sensitivity is True
+# Optimum: SupplierC (cheapest for every product) fills its 600 capacity; the
+# remaining 150 units of demand go to SupplierB at +2/unit -> 4550 + 300 = 4850.
+assert si.objective_value is not None and abs(si.objective_value - 4850) < 0.01, (
+ f"baseline objective changed: expected 4850, got {si.objective_value}"
+)
+
+baseline_objective = si.objective_value
+print(
+ f"\nBaseline status: {si.termination_status}, objective: {baseline_objective:.2f}"
+)
+
+# --- The baseline sourcing plan -------------------------------------------------
+# Read the solved amounts via values(0, ref) on the variable (no populate), filtered
+# to the lanes actually used in the query. (Only the objective is pinned above, not
+# these quantities: the optimum has cost-equal alternates, so the exact split is
+# solver-build-dependent -- contrast factory_production, whose unique plan is asserted.)
+amt = Float.ref()
+orders_df = (
+ model.select(
+ qty_var.supplyorder.supplier.name.alias("supplier"),
+ qty_var.supplyorder.product.name.alias("product"),
+ amt.alias("quantity"),
+ )
+ .where(qty_var.values(0, amt), amt > 1e-6)
+ .to_df()
+ .sort_values(["supplier", "product"], ignore_index=True)
+)
+print("\nBaseline orders:")
+print(orders_df.to_string(index=False))
+
+# --- Reduced costs: which lanes are priced out? ---------------------------------
+# Each marginal reads straight off the variable; the variable's back-pointer
+# (qty_var.supplyorder) joins it to the lane's supplier / product by ENTITY KEY.
+lane_rc_df = (
+ model.select(
+ qty_var.supplyorder.supplier.name.alias("supplier"),
+ qty_var.supplyorder.product.name.alias("product"),
+ qty_var.reduced_cost.alias("reduced_cost"),
+ qty_var.basis_status.alias("basis_status"),
+ )
+ .to_df()
+ .sort_values(["supplier", "product"], ignore_index=True)
+)
+print("\nLane reduced costs and basis status:")
+print(lane_rc_df.to_string(index=False))
+# A lane in use is BASIC with ~0 reduced cost; a priced-out lane is NONBASIC_AT_LOWER
+# with a positive reduced cost (how far its cost must fall before using it pays off).
+#
+# Complementary slackness, the ALWAYS-TRUE directions only. (1) SupplierA's and
+# SupplierD's lanes are genuinely priced out -- state it relationally, joined to the
+# supplier by key:
+model.where(qty_var.supplyorder.supplier.name == "SupplierA").require(
+ qty_var.reduced_cost > 1e-6
+)
+model.where(qty_var.supplyorder.supplier.name == "SupplierD").require(
+ qty_var.reduced_cost > 1e-6
+)
+# (these requires are validated when the next query below runs)
+# (2) Every lane actually in use prices at ~0. Read each lane's reduced cost and solved
+# amount together (values(k, ref) is the solution accessor) and check in Python. The
+# supplier column keeps each row identifiable -- and names the offender if the check
+# ever fails:
+rc_amt = Float.ref()
+cs_df = (
+ model.select(
+ qty_var.supplyorder.supplier.name.alias("supplier"),
+ qty_var.reduced_cost.alias("reduced_cost"),
+ rc_amt.alias("quantity"),
+ )
+ .where(qty_var.values(0, rc_amt))
+ .to_df()
+)
+assert (cs_df.loc[cs_df["quantity"] > 1e-6, "reduced_cost"].abs() < 1e-4).all()
+# NOT the converse "every unused lane has rc > 0": SupplierB's lanes are each exactly
+# +2 over SupplierC, so the optimum has alternate optima and some unused B lanes price
+# at ~0. The strict converse holds only under a unique optimum.
+
+# --- Shadow prices: the marginal value of capacity and demand -------------------
+# A constraint carries its declared entity key as a back-pointer too (cap.supplier /
+# meet.product), so a shadow price joins to that entity's own data by KEY -- no name
+# parsing, no pandas.
+cap_sp_df = (
+ model.select(
+ cap.supplier.name.alias("supplier"),
+ cap.supplier.capacity.alias("capacity"),
+ cap.shadow_price.alias("shadow_price"),
+ )
+ .to_df()
+ .sort_values("supplier", ignore_index=True)
+)
+print("\nSupplier capacity shadow prices (d cost / d capacity):")
+print(cap_sp_df.to_string(index=False))
+# Minimize + a <= capacity constraint => shadow_price <= 0: loosening a binding
+# capacity lowers cost. SupplierC is the only binding capacity (cheapest source, fills
+# up), so it carries the marginal value; the others have room to spare and price at 0.
+model.where(cap.supplier.name == "SupplierC").require(cap.shadow_price < -1e-6)
+model.where(cap.supplier.name != "SupplierC").require(
+ std.math.abs(cap.shadow_price) < 1e-6
+)
+# (like all requires, validated on the next query -- the demand read just below)
+
+demand_sp_df = (
+ model.select(
+ meet.product.name.alias("product"),
+ meet.product.demand.alias("demand"),
+ meet.shadow_price.alias("shadow_price"),
+ )
+ .to_df()
+ .sort_values("product", ignore_index=True)
+)
+print("\nProduct demand shadow prices (d cost / d demand):")
+print(demand_sp_df.to_string(index=False))
+# Minimize + a >= demand constraint => shadow_price >= 0: the marginal cost to serve
+# one more unit of that product. Every product's demand binds here, and each demand
+# shadow price is strictly positive because the marginal serving cost of each is > 0.
+model.where(meet.product.name == "Widget").require(meet.shadow_price > 1e-6)
+model.where(meet.product.name == "Gadget").require(meet.shadow_price > 1e-6)
+model.where(meet.product.name == "Component").require(meet.shadow_price > 1e-6)
+
+# --- Acting on it: which supplier capacity is the bottleneck? -------------------
+# The largest-magnitude capacity shadow price is the capacity whose marginal unit
+# moves the bill the most -- the supplier to expand (or protect) first. This read is
+# also the query that validates the demand requires stated just above: a
+# model.require() stays pending until the next query forces its evaluation.
+bottleneck_df = model.select(
+ cap.supplier.name.alias("supplier"), cap.shadow_price.alias("shadow_price")
+).to_df()
+bottleneck = max(
+ zip(bottleneck_df["supplier"], bottleneck_df["shadow_price"]),
+ key=lambda sp: abs(sp[1]),
+)
+print(
+ f"\nMost cost-sensitive capacity: {bottleneck[0]} (d cost / d capacity = {bottleneck[1]:+.2f})"
+)
-excluded_suppliers = [None, "SupplierC", "SupplierB"]
-scenario_results = []
-
-for excluded in excluded_suppliers:
- label = "baseline" if excluded is None else f"without_{excluded}"
+# --------------------------------------------------
+# Phase B -- disruption scenarios (separate re-solves)
+# --------------------------------------------------
+# Each scenario is its own Problem with a where= filter excluding one supplier -- a
+# FINITE structural change (a supplier fully removed). A shadow price is a marginal at
+# the current optimum, not an exclusion-impact ranking, so these re-solves are
+# CONSISTENT WITH Phase A (SupplierC, the most valuable capacity, hurts most when
+# excluded) without being predicted by the duals alone.
+
+# Known optima for the disruption scenarios (the LP objective is unique even under the
+# alternate optima above), asserted in the loop so a Phase B regression fails loudly.
+EXPECTED_SCENARIO_OBJECTIVE = {"without_SupplierC": 6750.0, "without_SupplierB": 5150.0}
+
+scenario_results = [
+ {
+ "scenario": "baseline",
+ "status": str(si.termination_status),
+ "objective": baseline_objective,
+ }
+]
+
+for excluded in ["SupplierC", "SupplierB"]:
+ label = f"without_{excluded}"
print(f"\nRunning scenario: {label}")
problem = Problem(model, Float)
- # Variable: order quantity — optionally exclude one supplier via where=
- where_clause = [SupplyOrder.supplier.name != excluded] if excluded is not None else None
- qty_var = problem.solve_for(
+ # Exclude one supplier via where=; populate=False leaves SupplyOrder.x_quantity
+ # untouched so the baseline's persistent integrity constraints stay valid.
+ qty_scn = problem.solve_for(
SupplyOrder.x_quantity,
name=["qty", SupplyOrder.supplier.name, SupplyOrder.product.name],
lower=0,
- where=where_clause,
+ where=[SupplyOrder.supplier.name != excluded],
populate=False,
)
- # Constraint: total orders from supplier cannot exceed supplier capacity
- capacity_limit = model.require(
- sum(SupplyOrder.x_quantity).where(SupplyOrder.supplier == Supplier).per(Supplier) <= Supplier.capacity
+ problem.satisfy(
+ model.require(
+ sum(SupplyOrder.x_quantity)
+ .where(SupplyOrder.supplier == Supplier)
+ .per(Supplier)
+ <= Supplier.capacity
+ )
)
- problem.satisfy(capacity_limit)
-
- # Constraint: demand satisfaction for each product
- meet_demand = model.require(
- sum(SupplyOrder.x_quantity).where(SupplyOrder.product == Product).per(Product) >= Product.demand
+ problem.satisfy(
+ model.require(
+ sum(SupplyOrder.x_quantity)
+ .where(SupplyOrder.product == Product)
+ .per(Product)
+ >= Product.demand
+ )
)
- problem.satisfy(meet_demand)
+ problem.minimize(sum(SupplyOrder.x_quantity * SupplyOrder.cost_per_unit))
- # Objective: minimize cost
- direct_cost = sum(SupplyOrder.x_quantity * SupplyOrder.cost_per_unit)
- problem.minimize(direct_cost)
-
- problem.display()
problem.solve("highs", time_limit_sec=60)
- si = problem.solve_info()
- si.display()
+ si_scn = problem.solve_info()
+ si_scn.display()
scenario_results.append(
{
"scenario": label,
- "status": str(si.termination_status),
- "objective": si.objective_value,
+ "status": str(si_scn.termination_status),
+ "objective": si_scn.objective_value,
}
)
- if si.termination_status != "OPTIMAL":
- print(f" Status: {si.termination_status} — skipping results")
+ if si_scn.termination_status != "OPTIMAL":
+ print(f" Status: {si_scn.termination_status} -- skipping results")
continue
- print(f" Status: {si.termination_status}, Objective: {si.objective_value}")
+ print(f" Status: {si_scn.termination_status}, Objective: {si_scn.objective_value}")
+ assert (
+ si_scn.objective_value is not None
+ and abs(si_scn.objective_value - EXPECTED_SCENARIO_OBJECTIVE[label]) < 0.01
+ ), (
+ f"{label} objective changed: expected {EXPECTED_SCENARIO_OBJECTIVE[label]}, "
+ f"got {si_scn.objective_value}"
+ )
- # Print order plan from solver results
value_ref = Float.ref()
- qty_df = model.select(
- qty_var.supplyorder.supplier.name.alias("supplier"),
- qty_var.supplyorder.product.name.alias("product"),
- value_ref.alias("quantity"),
- ).where(qty_var.values(0, value_ref), value_ref > 0.001).to_df()
+ qty_df = (
+ model.select(
+ qty_scn.supplyorder.supplier.name.alias("supplier"),
+ qty_scn.supplyorder.product.name.alias("product"),
+ value_ref.alias("quantity"),
+ )
+ .where(qty_scn.values(0, value_ref), value_ref > 1e-6)
+ .to_df()
+ .sort_values(["supplier", "product"], ignore_index=True)
+ )
print("\n Orders:")
print(qty_df.to_string(index=False))