From f5af427698f79cc76cdf770b85b74cd1f30a4f59 Mon Sep 17 00:00:00 2001 From: Chris Coey Date: Tue, 2 Jun 2026 14:26:26 -0700 Subject: [PATCH 01/17] Illustrate prescriptive sensitivity and conflict analysis in templates Extend three prescriptive-optimization templates to demonstrate the new sensitivity (LP marginals) and conflict (IIS) analysis available on solve(): - factory_production: a maximize-profit product-mix LP that reads capacity shadow prices and product reduced costs / basis status from a single solve. - supplier_reliability: a minimize-cost sourcing LP with capacity and demand shadow prices, lane reduced costs, plus supplier-disruption scenarios. - cicd_runner_allocation: a binary assignment MIP that diagnoses an infeasible maintenance outage via conflict (IIS) membership. Each marginal or conflict joins back to its grounding entity by key through constraint back-pointers. Validated end-to-end against the solver. Co-Authored-By: Claude Opus 4.8 --- v1/cicd_runner_allocation/README.md | 114 +++++-- .../cicd_runner_allocation.py | 230 +++++++++++--- v1/factory_production/README.md | 150 +++++---- v1/factory_production/factory_production.py | 294 ++++++++++++++---- v1/supplier_reliability/README.md | 169 +++++++--- .../supplier_reliability.py | 290 ++++++++++++++--- 6 files changed, 965 insertions(+), 282 deletions(-) diff --git a/v1/cicd_runner_allocation/README.md b/v1/cicd_runner_allocation/README.md index 0908095..3f58c3c 100644 --- a/v1/cicd_runner_allocation/README.md +++ b/v1/cicd_runner_allocation/README.md @@ -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 @@ -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; the status, cost, and conflict diagnosis 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 (4 jobs): build-api, build-frontend, lint-and-format, 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 (5 jobs): dependency-audit, deploy-production, deploy-staging, 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 (3 jobs): build-frontend, lint-and-format, release-notes + ubuntu-latest (5 jobs): dependency-audit, deploy-production, deploy-staging, 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 (3 jobs): deploy-production, lint-and-format, release-notes + ubuntu-latest (1 jobs): deploy-staging windows-latest (1 jobs): windows-installer ================================================== @@ -135,13 +139,46 @@ 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 + ================================================== + Status: INFEASIBLE + Conflict status: CONFLICT_FOUND + + Stranded jobs (assign-one rule in conflict): + build-mobile-android + docker-build + e2e-tests-chrome + integration-tests + performance-tests + unit-tests-api + + Binding runner caps (concurrency rule in conflict): + ubuntu-xlarge 5 + + Jobs in conflict: ['build-mobile-android', 'docker-build', 'e2e-tests-chrome', 'integration-tests', 'performance-tests', 'unit-tests-api'] + Runner caps in conflict: ['ubuntu-xlarge'] + + 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 keeping the high-CPU jobs off the pricier ubuntu-large and ubuntu-xlarge runners. (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 +206,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 +215,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 +253,27 @@ 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** and **named per entity** so its conflict membership can be read back by key 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], +) ``` 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], +) ``` The objective minimizes total pipeline cost -- the sum of (runner cost per minute * job duration) across all assignments: @@ -252,7 +296,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 +307,34 @@ 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 `{ubuntu-large, ubuntu-xlarge, self-hosted-linux}`, 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") +assert outage.si.conflict_status == "CONFLICT_FOUND" +``` + +`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 carries an **entity back-pointer** (`assign_one.workflow`, `conc.runner`), mirroring the variable's 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() +``` + +The IIS is minimal: it names **six of the seven** high-CPU jobs (six already exceed the cap of five) 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. + ## Customize this template - **Add runners**: Extend `runners.csv` with new runner types (e.g., GPU runners for ML workflows). @@ -276,7 +348,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..74ae537 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,28 @@ - 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 carries an +entity back-pointer to what it grounds over (``assign_one.workflow`` / ``conc.runner``), +so 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. """ +from collections import namedtuple from pathlib import Path from pandas import read_csv @@ -118,36 +133,55 @@ # 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) or IIS membership +# (infeasible) by entity key. +Allocation = namedtuple("Allocation", "si assign_var assign_one conc") + - Returns (solve_info, assignment_df) or None if infeasible. +def solve_allocation(concurrency_multiplier, offline_runners=(), conflict=False): + """Solve runner assignment under a concurrency cap. + + ``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. + # Decision variable: binary assignment of workflow to runner. A maintenance + # outage drops the offline runners' assignments via where=. + 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: each workflow assigned to exactly one runner. Named per workflow so + # its IIS membership reads back by key (assign_one.workflow). + assign_one = problem.satisfy( + model.require( + sum(AssignRef.x_assigned) + .where(AssignRef.workflow == Workflow) + .per(Workflow) + == 1 + ), + name=["assign_one", Workflow.name], + ) - # 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 - )) + # Constraint: per-runner concurrency limit (scaled by scenario multiplier). Named + # per runner so its IIS membership reads back by 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], + ) # Objective: minimize total pipeline cost. problem.minimize( @@ -158,25 +192,52 @@ def solve_allocation(concurrency_multiplier): ) ) - problem.solve("highs", time_limit_sec=60) - si = problem.solve_info() + problem.solve("highs", time_limit_sec=60, conflict=conflict) + return Allocation(problem.solve_info(), assign_var, assign_one, conc) - if si.termination_status not in ("OPTIMAL", "LOCALLY_SOLVED"): - return None +def assignment_df(assign_var): + """The chosen (workflow, runner) assignments, read off the variable by key.""" 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() - - return si, assign_df + 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 {ubuntu-large, ubuntu-xlarge, +# self-hosted-linux}; with two of those three 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", + ] +) + + if __name__ == "__main__": scenario_results = [] @@ -185,33 +246,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 +292,76 @@ 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") + assert outage.si.conflict_status == "CONFLICT_FOUND" + + # in_conflict is a bare predicate on each constraint instance; the entity + # back-pointer (assign_one.workflow / conc.runner) joins the IIS to the actual + # stranded jobs and the binding runner cap by KEY -- no rule-name parsing. + print("\nStranded jobs (assign-one rule in conflict):") + model.select(outage.assign_one.workflow.name).where( + outage.assign_one.in_conflict + ).inspect() + print("\nBinding runner caps (concurrency rule in conflict):") + model.select(outage.conc.runner.name, outage.conc.runner.max_concurrent).where( + outage.conc.in_conflict + ).inspect() + + stranded = set( + model.select(outage.assign_one.workflow.name) + .where(outage.assign_one.in_conflict) + .to_df() + .iloc[:, 0] + ) + caps = set( + model.select(outage.conc.runner.name) + .where(outage.conc.in_conflict) + .to_df() + .iloc[:, 0] + ) + print("\nJobs in conflict:", sorted(stranded)) + print("Runner caps in conflict:", sorted(caps)) + + # 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 exercises + # in_conflict on the equality (== 1) rows -- the on-engine validation point for + # PyRel #1617. + 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) + + # State part of the diagnosis as an integrity constraint joined by key: the + # ubuntu-xlarge concurrency rule must be in the conflict. (A require only runs + # when the model is next queried, so the select below also forces it.) + model.where(outage.conc.runner.name == "ubuntu-xlarge").require( + outage.conc.in_conflict + ) + model.select(outage.conc.runner.name).where(outage.conc.in_conflict).inspect() + + 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." + ) diff --git a/v1/factory_production/README.md b/v1/factory_production/README.md index 2a2d32b..57f2973 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**: the cleanest setting in this portfolio 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 — the binding product is `NONBASIC_AT_UPPER` (its demand cap) here, versus `NONBASIC_AT_LOWER` (zero) 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. +> 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 @@ -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,75 @@ 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`) and named per factory, so each instance's shadow price reads back through the constraint's **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], +) -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 an entity back-pointer (`cap.factory`) and a variable carries 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() +``` + +Sensitivity marginals are exact for a linear program. They describe the rate of change at the current optimum, valid over a range; 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 30 hours, so the shadow price holds at 4200; only below 30 hours does `bands` become the swing and the price rise 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. +That factory has idle resource-hours — its capacity is *not* binding, so an extra hour buys nothing. Its bottleneck is elsewhere (typically product demand). This is expected, not an error: compare the `idle` column in the capacity summary.
@@ -206,7 +250,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..39ded4a 100644 --- a/v1/factory_production/factory_production.py +++ b/v1/factory_production/factory_production.py @@ -1,25 +1,57 @@ """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). + +Each constraint carries an entity back-pointer to what it grounds over +(``cap.factory``), just like a variable points back to its product +(``quantity_var.product``), so a marginal joins to that entity's own data by ENTITY +KEY (``cap.factory.avail``) -- never by parsing the constraint name string. 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 +67,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 +75,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 +96,206 @@ 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}" +) + # -------------------------------------------------- # 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. + +problem = Problem(model, Float) -scenario_results = [] +# 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 and named per factory so each instance's shadow price reads back through the +# constraint's ENTITY KEY (cap.factory), never by parsing the constraint name string. +cap = problem.satisfy( + model.require( + sum(Product.x_quantity / Product.rate) + .where(Product.factory == Factory) + .per(Factory) + <= Factory.avail + ), + name=["cap", Factory.name], +) -for factory_name in SCENARIO_VALUES: - print(f"\nFor factory: {factory_name}") +# Objective: maximize total profit across all factories. +problem.maximize(sum(Product.profit * Product.x_quantity)) - # Restrict to products of this factory - this_product = Product.factory.name(factory_name) +problem.display() +problem.solve("highs", time_limit_sec=60, sensitivity=True) +si = problem.solve_info() +si.display() - problem = Problem(model, Float) +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 + +print( + f"\nBaseline status: {str(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: a matching objective is necessary +# but not sufficient (an alternate optimum could reach 200000 with a different mix). +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) < 1.0, ( + 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 an entity 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() -# Summary +# --- 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 — capacity utilization per factory +# -------------------------------------------------- +# Ties the shadow prices above back to the plan: a factory with zero idle hours is the +# binding one (positive shadow price); idle hours mean a slack capacity (zero price). +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 30 hrs, so the shadow price holds at 4200; only below 30 hrs +# can bands no longer fill its 6000 demand -- bands becomes the swing and the price +# rises to 5000 (its own profit-per-hour, 25 x 200). diff --git a/v1/supplier_reliability/README.md b/v1/supplier_reliability/README.md index 8359824..37e47d3 100644 --- a/v1/supplier_reliability/README.md +++ b/v1/supplier_reliability/README.md @@ -1,6 +1,6 @@ --- 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" @@ -9,6 +9,7 @@ reasoning_types: 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 also requests **sensitivity analysis** (`solve(sensitivity=True)`) on the baseline, which 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 @@ -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 (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 Widget 150.0 + SupplierC Component 200.0 + SupplierC Gadget 250.0 + SupplierC Widget 150.0 + + Lane reduced costs and basis status: + supplier product reduced_cost basis_status + SupplierA Widget 2.0 NONBASIC_AT_LOWER + SupplierA Gadget 3.0 NONBASIC_AT_LOWER + SupplierB Widget 0.0 BASIC + SupplierB Gadget 0.0 NONBASIC_AT_LOWER + SupplierB Component 0.0 NONBASIC_AT_LOWER + SupplierC Widget 0.0 BASIC + SupplierC Gadget 0.0 BASIC + SupplierC Component 0.0 BASIC + SupplierD Gadget 2.0 NONBASIC_AT_LOWER + SupplierD Component 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 + Widget 300 8.0 + Gadget 250 9.0 + Component 200 7.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 150.0 + SupplierB Gadget 250.0 + SupplierD Component 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,24 @@ 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 (`8`, `9`, `7`) 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 more expensive 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 +203,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 +229,68 @@ 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** and **named per entity** so its marginal can be read back by key after the solve: ```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)) +cap = baseline.satisfy( + model.require( + sum(SupplyOrder.x_quantity).where(SupplyOrder.supplier == Supplier).per(Supplier) <= Supplier.capacity + ), + name=["cap", Supplier.name], +) +meet = baseline.satisfy( + model.require( + sum(SupplyOrder.x_quantity).where(SupplyOrder.product == Product).per(Product) >= Product.demand + ), + name=["demand", Product.name], +) +baseline.minimize(sum(SupplyOrder.x_quantity * SupplyOrder.cost_per_unit)) ``` -### 4. Scenario analysis +### 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`. Each constraint carries an **entity back-pointer** (`cap.supplier`, `meet.product`), mirroring the variable's back-pointer (`qty_var.supplyorder`), so a marginal joins to that entity's own data by KEY -- no name parsing, no pandas: + +```python +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() +``` + +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 (a binding mix of `<=`/`>=`/`=` 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/supplier_reliability.py b/v1/supplier_reliability/supplier_reliability.py index 3a556d8..5e1b783 100644 --- a/v1/supplier_reliability/supplier_reliability.py +++ b/v1/supplier_reliability/supplier_reliability.py @@ -1,33 +1,57 @@ """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. + +Each constraint carries an entity back-pointer to what it grounds over +(``cap.supplier`` / ``meet.product``), just like a variable points back to its lane +(``qty_var.supplyorder``), so a marginal joins to that entity's own data by ENTITY +KEY (``cap.supplier.capacity``) -- never by parsing the constraint name string. 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 +67,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 +84,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 +93,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 +122,226 @@ 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 and named per entity so each instance's marginal +# reads back through the constraint's ENTITY KEY (cap.supplier / meet.product), never +# by parsing the constraint name string. +cap = baseline.satisfy( + model.require( + sum(SupplyOrder.x_quantity) + .where(SupplyOrder.supplier == Supplier) + .per(Supplier) + <= Supplier.capacity + ), + name=["cap", Supplier.name], +) +meet = baseline.satisfy( + model.require( + sum(SupplyOrder.x_quantity).where(SupplyOrder.product == Product).per(Product) + >= Product.demand + ), + name=["demand", Product.name], +) + +# 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 + +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. +amt = Float.ref() +print("\nBaseline orders:") +model.select( + qty_var.supplyorder.supplier.name, + qty_var.supplyorder.product.name, + amt, +).where(qty_var.values(0, amt), amt > 1e-6).inspect() + +# --- 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. +print("\nLane reduced costs and basis status:") +model.select( + qty_var.supplyorder.supplier.name, + qty_var.supplyorder.product.name, + qty_var.reduced_cost, + qty_var.basis_status, +).inspect() +# 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 +) +model.select(qty_var.supplyorder.supplier.name, qty_var.reduced_cost).inspect() +# (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: +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 an entity 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. +print("\nSupplier capacity shadow prices (d cost / d capacity):") +model.select(cap.supplier.name, cap.supplier.capacity, cap.shadow_price).inspect() +# 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 +) + +print("\nProduct demand shadow prices (d cost / d demand):") +model.select(meet.product.name, meet.product.demand, meet.shadow_price).inspect() +# 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) +model.select(meet.product.name, meet.shadow_price).inspect() + +# --- 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. +cap_df = model.select(cap.supplier.name.alias("s"), cap.shadow_price.alias("p")).to_df() +bottleneck = max(zip(cap_df["s"], cap_df["p"]), 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) - - # Objective: minimize cost - direct_cost = sum(SupplyOrder.x_quantity * SupplyOrder.cost_per_unit) - problem.minimize(direct_cost) + problem.minimize(sum(SupplyOrder.x_quantity * SupplyOrder.cost_per_unit)) - 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 abs(si_scn.objective_value - EXPECTED_SCENARIO_OBJECTIVE[label]) < 0.01 - # 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() + ) print("\n Orders:") print(qty_df.to_string(index=False)) From d1a68ed39f88ea51a5120e7fd193c55e1be2a7cf Mon Sep 17 00:00:00 2001 From: Chris Coey Date: Tue, 2 Jun 2026 19:41:24 -0700 Subject: [PATCH 02/17] Refine sensitivity/conflict templates: deterministic output, status dispatch, sharper marginals prose - Aliased + sorted marginal and conflict tables so each README's expected-output matches actual stdout deterministically (baseline and scenario displays) - Dispatch on conflict_status (read the IIS only for CONFLICT_FOUND) instead of asserting a single value, with a clear reason on other outcomes - Correct the degenerate-breakpoint and one-way shadow-price wording; add a product-name uniqueness guard and tighten the plan tolerance - Silence the benign rules-in-a-loop warning from the scenario sweep, by message Co-Authored-By: Claude Opus 4.8 --- v1/cicd_runner_allocation/README.md | 38 +++-- .../cicd_runner_allocation.py | 136 ++++++++++-------- v1/factory_production/README.md | 4 +- v1/factory_production/factory_production.py | 31 ++-- v1/supplier_reliability/README.md | 52 +++---- .../supplier_reliability.py | 72 +++++++--- 6 files changed, 204 insertions(+), 129 deletions(-) diff --git a/v1/cicd_runner_allocation/README.md b/v1/cicd_runner_allocation/README.md index 3f58c3c..8fae034 100644 --- a/v1/cicd_runner_allocation/README.md +++ b/v1/cicd_runner_allocation/README.md @@ -143,22 +143,25 @@ Finally, it shows **conflict analysis (infeasibility diagnosis)**. A maintenance ================================================== Maintenance outage: ubuntu-large, self-hosted-linux offline ================================================== - Status: INFEASIBLE - Conflict status: CONFLICT_FOUND + Solve result: + • status: INFEASIBLE + • primal status: NO_SOLUTION + • dual status: INFEASIBILITY_CERTIFICATE + • conflict status: CONFLICT_FOUND + ... Stranded jobs (assign-one rule in conflict): - build-mobile-android - docker-build - e2e-tests-chrome - integration-tests - performance-tests - unit-tests-api + workflow + build-mobile-android + docker-build + e2e-tests-chrome + integration-tests + performance-tests + unit-tests-api Binding runner caps (concurrency rule in conflict): - ubuntu-xlarge 5 - - Jobs in conflict: ['build-mobile-android', 'docker-build', 'e2e-tests-chrome', 'integration-tests', 'performance-tests', 'unit-tests-api'] - Runner caps in conflict: ['ubuntu-xlarge'] + 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. ... @@ -316,7 +319,14 @@ outage = solve_allocation(1.0, offline_runners=["ubuntu-large", "self-hosted-lin assert outage.si.conflict is True assert outage.si.termination_status in ("INFEASIBLE", "INFEASIBLE_OR_UNBOUNDED") -assert outage.si.conflict_status == "CONFLICT_FOUND" + +# 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}") ``` `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 carries an **entity back-pointer** (`assign_one.workflow`, `conc.runner`), mirroring the variable's back-pointer, so the conflict reads back as the actual stranded jobs and the binding runner cap, joined by KEY -- no rule-name parsing: @@ -330,7 +340,7 @@ model.select(outage.conc.runner.name, outage.conc.runner.max_concurrent).where( ).inspect() ``` -The IIS is minimal: it names **six of the seven** high-CPU jobs (six already exceed the cap of five) 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. +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. diff --git a/v1/cicd_runner_allocation/cicd_runner_allocation.py b/v1/cicd_runner_allocation/cicd_runner_allocation.py index 74ae537..18fdf19 100644 --- a/v1/cicd_runner_allocation/cicd_runner_allocation.py +++ b/v1/cicd_runner_allocation/cicd_runner_allocation.py @@ -31,6 +31,7 @@ jobs and the binding concurrency cap. """ +import warnings from collections import namedtuple from pathlib import Path @@ -38,6 +39,15 @@ from relationalai.semantics import Float, Integer, Model, String, sum from relationalai.semantics.reasoners.prescriptive import Problem +# This template re-solves the model once per scenario (the concurrency sweep) and again +# for the outage. Rebuilding the Problem -- with its per-workflow and per-runner *named* +# constraints -- in that loop trips PyRel's "rules created in a loop" heuristic. That +# 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 this one by +# message and leave every other warning visible. +warnings.filterwarnings("ignore", message=r"\[Rules created in a loop\]") + # -------------------------------------------------- # Configure inputs # -------------------------------------------------- @@ -149,7 +159,10 @@ def solve_allocation(concurrency_multiplier, offline_runners=(), conflict=False) problem = Problem(model, Float) # Decision variable: binary assignment of workflow to runner. A maintenance - # outage drops the offline runners' assignments via where=. + # outage drops the offline runners' assignments via where=. With no offline + # runners the comprehension is empty and `[] or None` collapses to None, i.e. + # "no filter" -- solve_for treats where=None and where=[] differently, so the + # None is deliberate. where_clause = [Assignment.runner.name != r for r in offline_runners] or None assign_var = problem.solve_for( Assignment.x_assigned, @@ -307,61 +320,68 @@ def assignment_df(assign_var): 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") - assert outage.si.conflict_status == "CONFLICT_FOUND" - - # in_conflict is a bare predicate on each constraint instance; the entity - # back-pointer (assign_one.workflow / conc.runner) joins the IIS to the actual - # stranded jobs and the binding runner cap by KEY -- no rule-name parsing. - print("\nStranded jobs (assign-one rule in conflict):") - model.select(outage.assign_one.workflow.name).where( - outage.assign_one.in_conflict - ).inspect() - print("\nBinding runner caps (concurrency rule in conflict):") - model.select(outage.conc.runner.name, outage.conc.runner.max_concurrent).where( - outage.conc.in_conflict - ).inspect() - - stranded = set( - model.select(outage.assign_one.workflow.name) - .where(outage.assign_one.in_conflict) - .to_df() - .iloc[:, 0] - ) - caps = set( - model.select(outage.conc.runner.name) - .where(outage.conc.in_conflict) - .to_df() - .iloc[:, 0] - ) - print("\nJobs in conflict:", sorted(stranded)) - print("Runner caps in conflict:", sorted(caps)) - - # 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 exercises - # in_conflict on the equality (== 1) rows -- the on-engine validation point for - # PyRel #1617. - 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) - # State part of the diagnosis as an integrity constraint joined by key: the - # ubuntu-xlarge concurrency rule must be in the conflict. (A require only runs - # when the model is next queried, so the select below also forces it.) - model.where(outage.conc.runner.name == "ubuntu-xlarge").require( - outage.conc.in_conflict - ) - model.select(outage.conc.runner.name).where(outage.conc.in_conflict).inspect() - - 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." - ) + # 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 entity + # back-pointer (assign_one.workflow / conc.runner) joins the IIS to the actual + # stranded jobs and the binding runner cap by KEY -- 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 exercises + # in_conflict on the equality (== 1) rows -- the on-engine validation point for + # PyRel #1617. + 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/factory_production/README.md b/v1/factory_production/README.md index 57f2973..1b3fa19 100644 --- a/v1/factory_production/README.md +++ b/v1/factory_production/README.md @@ -219,7 +219,7 @@ Sensitivity marginals are exact for a linear program. They describe the rate of ## Customize this template - **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 30 hours, so the shadow price holds at 4200; only below 30 hours does `bands` become the swing and the price rise to 5000. +- **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. @@ -234,7 +234,7 @@ Sensitivity marginals are an LP/QP concept. They are populated only when the pro
A factory's capacity shadow price is zero -That factory has idle resource-hours — its capacity is *not* binding, so an extra hour buys nothing. Its bottleneck is elsewhere (typically product demand). This is expected, not an error: compare the `idle` column in the capacity summary. +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.)
diff --git a/v1/factory_production/factory_production.py b/v1/factory_production/factory_production.py index 39ded4a..ea50e0e 100644 --- a/v1/factory_production/factory_production.py +++ b/v1/factory_production/factory_production.py @@ -107,6 +107,10 @@ 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 @@ -163,9 +167,7 @@ # 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 -print( - f"\nBaseline status: {str(si.termination_status)}, total profit: {si.objective_value:.2f}" -) +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 @@ -189,12 +191,16 @@ produced = plan_df.loc[plan_df["quantity"] > 1e-6, ["factory", "product", "quantity"]] print(produced.to_string(index=False)) -# Pin the whole plan, not just the objective scalar: a matching objective is necessary -# but not sufficient (an alternate optimum could reach 200000 with a different mix). +# 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) < 1.0, ( + assert abs(actual_qty - expected_qty) < 0.01, ( f"{product_name}: expected {expected_qty}, got {actual_qty}" ) @@ -275,8 +281,10 @@ # -------------------------------------------------- # Summary — capacity utilization per factory # -------------------------------------------------- -# Ties the shadow prices above back to the plan: a factory with zero idle hours is the -# binding one (positive shadow price); idle hours mean a slack capacity (zero price). +# 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") @@ -296,6 +304,7 @@ # - 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 30 hrs, so the shadow price holds at 4200; only below 30 hrs -# can bands no longer fill its 6000 demand -- bands becomes the swing and the price -# rises to 5000 (its own profit-per-hour, 25 x 200). +# 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/supplier_reliability/README.md b/v1/supplier_reliability/README.md index 37e47d3..f20a8b1 100644 --- a/v1/supplier_reliability/README.md +++ b/v1/supplier_reliability/README.md @@ -94,42 +94,42 @@ Finally, the template demonstrates **scenario analysis** by re-solving the probl python supplier_reliability.py ``` -6. Expected output (marginal tables are read back by entity key): +6. Expected output (model and solver display trimmed; marginal tables are read back by entity key): ```text Baseline status: OPTIMAL, objective: 4850.00 Baseline orders: - supplier product quantity - SupplierB Widget 150.0 - SupplierC Component 200.0 - SupplierC Gadget 250.0 - SupplierC Widget 150.0 + supplier product quantity + SupplierB Widget 150.0 + SupplierC Component 200.0 + SupplierC Gadget 250.0 + SupplierC Widget 150.0 Lane reduced costs and basis status: - supplier product reduced_cost basis_status - SupplierA Widget 2.0 NONBASIC_AT_LOWER - SupplierA Gadget 3.0 NONBASIC_AT_LOWER - SupplierB Widget 0.0 BASIC - SupplierB Gadget 0.0 NONBASIC_AT_LOWER - SupplierB Component 0.0 NONBASIC_AT_LOWER - SupplierC Widget 0.0 BASIC - SupplierC Gadget 0.0 BASIC - SupplierC Component 0.0 BASIC - SupplierD Gadget 2.0 NONBASIC_AT_LOWER - SupplierD Component 2.0 NONBASIC_AT_LOWER + supplier product reduced_cost basis_status + SupplierA Gadget 3.0 NONBASIC_AT_LOWER + SupplierA Widget 2.0 NONBASIC_AT_LOWER + SupplierB Component 0.0 NONBASIC_AT_LOWER + SupplierB Gadget 0.0 NONBASIC_AT_LOWER + SupplierB Widget 0.0 BASIC + 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 + 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 - Widget 300 8.0 - Gadget 250 9.0 - Component 200 7.0 + 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) diff --git a/v1/supplier_reliability/supplier_reliability.py b/v1/supplier_reliability/supplier_reliability.py index 5e1b783..73a42bd 100644 --- a/v1/supplier_reliability/supplier_reliability.py +++ b/v1/supplier_reliability/supplier_reliability.py @@ -181,23 +181,34 @@ # Read the solved amounts via values(0, ref) on the variable (no populate), filtered # to the lanes actually used in the query. 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:") -model.select( - qty_var.supplyorder.supplier.name, - qty_var.supplyorder.product.name, - amt, -).where(qty_var.values(0, amt), amt > 1e-6).inspect() +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:") -model.select( - qty_var.supplyorder.supplier.name, - qty_var.supplyorder.product.name, - qty_var.reduced_cost, - qty_var.basis_status, -).inspect() +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). # @@ -210,7 +221,7 @@ model.where(qty_var.supplyorder.supplier.name == "SupplierD").require( qty_var.reduced_cost > 1e-6 ) -model.select(qty_var.supplyorder.supplier.name, qty_var.reduced_cost).inspect() +# (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: rc_amt = Float.ref() @@ -231,8 +242,17 @@ # --- Shadow prices: the marginal value of capacity and demand ------------------- # A constraint carries an entity 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):") -model.select(cap.supplier.name, cap.supplier.capacity, cap.shadow_price).inspect() +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. @@ -241,21 +261,36 @@ std.math.abs(cap.shadow_price) < 1e-6 ) +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):") -model.select(meet.product.name, meet.product.demand, meet.shadow_price).inspect() +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) -model.select(meet.product.name, meet.shadow_price).inspect() # --- 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. -cap_df = model.select(cap.supplier.name.alias("s"), cap.shadow_price.alias("p")).to_df() -bottleneck = max(zip(cap_df["s"], cap_df["p"]), key=lambda sp: abs(sp[1])) +# 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})" ) @@ -341,6 +376,7 @@ ) .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)) From 24057824fd4d992587f66e6d5e2f9deda91776e6 Mon Sep 17 00:00:00 2001 From: Chris Coey Date: Wed, 3 Jun 2026 23:45:42 -0700 Subject: [PATCH 03/17] Declare constraint entity keys with keyed_by The constraint back-pointer that joins a marginal or conflict membership to its entity's data is now declared explicitly via satisfy(keyed_by={...}) rather than detected automatically. Declare the keys in all three templates and align the docstring/README wording. Variable back-pointers are unchanged (still derived from field names). Validated end-to-end: supplier_reliability and factory_production return the documented optima and marginals; cicd_runner_allocation returns CONFLICT_FOUND with the documented IIS (six stranded high-CPU jobs + the ubuntu-xlarge cap). Co-Authored-By: Claude Opus 4.8 --- v1/cicd_runner_allocation/README.md | 6 +++-- .../cicd_runner_allocation.py | 25 +++++++++++-------- v1/factory_production/README.md | 5 ++-- v1/factory_production/factory_production.py | 19 ++++++++------ v1/supplier_reliability/README.md | 6 +++-- .../supplier_reliability.py | 23 ++++++++++------- 6 files changed, 50 insertions(+), 34 deletions(-) diff --git a/v1/cicd_runner_allocation/README.md b/v1/cicd_runner_allocation/README.md index 8fae034..957cd5c 100644 --- a/v1/cicd_runner_allocation/README.md +++ b/v1/cicd_runner_allocation/README.md @@ -256,7 +256,7 @@ problem.solve_for( ) ``` -Two constraints enforce feasibility. Each is **captured as a handle** and **named per entity** so its conflict membership can be read back by key if the model turns out infeasible. 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 assign_one = problem.satisfy( @@ -264,6 +264,7 @@ assign_one = problem.satisfy( sum(AssignRef.x_assigned).where(AssignRef.workflow == Workflow).per(Workflow) == 1 ), name=["assign_one", Workflow.name], + keyed_by={"workflow": Workflow}, ) ``` @@ -276,6 +277,7 @@ conc = problem.satisfy( <= concurrency_multiplier * Runner.max_concurrent ), name=["concurrency", Runner.name], + keyed_by={"runner": Runner}, ) ``` @@ -329,7 +331,7 @@ else: print(f"No IIS to inspect: {outage.si.conflict_status}") ``` -`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 carries an **entity back-pointer** (`assign_one.workflow`, `conc.runner`), mirroring the variable's back-pointer, so the conflict reads back as the actual stranded jobs and the binding runner cap, joined by KEY -- no rule-name parsing: +`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): diff --git a/v1/cicd_runner_allocation/cicd_runner_allocation.py b/v1/cicd_runner_allocation/cicd_runner_allocation.py index 18fdf19..06a5e35 100644 --- a/v1/cicd_runner_allocation/cicd_runner_allocation.py +++ b/v1/cicd_runner_allocation/cicd_runner_allocation.py @@ -17,10 +17,10 @@ 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 carries an -entity back-pointer to what it grounds over (``assign_one.workflow`` / ``conc.runner``), -so the conflict reads back as the actual *stranded jobs* and the *binding runner cap*, -joined by KEY -- no rule-name parsing. +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` @@ -174,8 +174,9 @@ def solve_allocation(concurrency_multiplier, offline_runners=(), conflict=False) AssignRef = Assignment.ref() - # Constraint: each workflow assigned to exactly one runner. Named per workflow so - # its IIS membership reads back by key (assign_one.workflow). + # 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) @@ -184,16 +185,18 @@ def solve_allocation(concurrency_multiplier, offline_runners=(), conflict=False) == 1 ), name=["assign_one", Workflow.name], + keyed_by={"workflow": Workflow}, ) - # Constraint: per-runner concurrency limit (scaled by scenario multiplier). Named - # per runner so its IIS membership reads back by key (conc.runner). + # 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}, ) # Objective: minimize total pipeline cost. @@ -329,9 +332,9 @@ def assignment_df(assign_var): # 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 entity - # back-pointer (assign_one.workflow / conc.runner) joins the IIS to the actual - # stranded jobs and the binding runner cap by KEY -- no rule-name parsing. + # 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")) diff --git a/v1/factory_production/README.md b/v1/factory_production/README.md index 1b3fa19..6ed7402 100644 --- a/v1/factory_production/README.md +++ b/v1/factory_production/README.md @@ -180,7 +180,7 @@ quantity_var = problem.solve_for( ### 3. Capacity constraint and objective -Each factory's total resource usage must not exceed its availability. The constraint is captured as a handle (`cap`) and named per factory, so each instance's shadow price reads back through the constraint's **entity key** (`cap.factory`) rather than by parsing a name string. The objective maximizes total profit across all factories: +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 cap = problem.satisfy( @@ -191,6 +191,7 @@ cap = problem.satisfy( <= Factory.avail ), name=["cap", Factory.name], + keyed_by={"factory": Factory}, ) problem.maximize(sum(Product.profit * Product.x_quantity)) @@ -199,7 +200,7 @@ problem.solve("highs", time_limit_sec=60, sensitivity=True) ### 4. Read the sensitivity marginals -After a `sensitivity=True` solve, the marginals are attributes on the captured handles. A constraint carries an entity back-pointer (`cap.factory`) and a variable carries one to its product (`quantity_var.product`), so each marginal joins to that entity's own data by key — no pandas, no name parsing: +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? diff --git a/v1/factory_production/factory_production.py b/v1/factory_production/factory_production.py index ea50e0e..c6ed1c4 100644 --- a/v1/factory_production/factory_production.py +++ b/v1/factory_production/factory_production.py @@ -35,10 +35,11 @@ *constraint* (its marginal is a shadow price), while the demand cap is a variable *upper bound* (its marginal is a reduced cost). -Each constraint carries an entity back-pointer to what it grounds over -(``cap.factory``), just like a variable points back to its product -(``quantity_var.product``), so a marginal joins to that entity's own data by ENTITY -KEY (``cap.factory.avail``) -- never by parsing the constraint name string. +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` @@ -140,8 +141,9 @@ ) # Constraint: each factory's total resource usage <= its available hours. Captured as -# a handle and named per factory so each instance's shadow price reads back through the -# constraint's ENTITY KEY (cap.factory), never by parsing the constraint name string. +# 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) @@ -150,6 +152,7 @@ <= Factory.avail ), name=["cap", Factory.name], + keyed_by={"factory": Factory}, ) # Objective: maximize total profit across all factories. @@ -205,8 +208,8 @@ ) # --- Capacity shadow prices: which factory to expand first ---------------------- -# A constraint carries an entity back-pointer (cap.factory), so a shadow price joins -# to that factory's own data by KEY -- no name parsing, no pandas join. +# 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"), diff --git a/v1/supplier_reliability/README.md b/v1/supplier_reliability/README.md index f20a8b1..d08dd54 100644 --- a/v1/supplier_reliability/README.md +++ b/v1/supplier_reliability/README.md @@ -229,7 +229,7 @@ model.define(SupplyOrder.new(option=SupplyOption)) ### 3. Add constraints and objective -Capacity and demand constraints ensure feasibility, while the objective minimizes total procurement cost. Each constraint is **captured as a handle** and **named per entity** so its marginal can be read back by key after the solve: +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( @@ -237,19 +237,21 @@ cap = baseline.satisfy( 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`. Each constraint carries an **entity back-pointer** (`cap.supplier`, `meet.product`), mirroring the variable's back-pointer (`qty_var.supplyorder`), so a marginal joins to that entity's own data by KEY -- no name parsing, no pandas: +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 baseline.solve("highs", time_limit_sec=60, sensitivity=True) diff --git a/v1/supplier_reliability/supplier_reliability.py b/v1/supplier_reliability/supplier_reliability.py index 73a42bd..e8e0fae 100644 --- a/v1/supplier_reliability/supplier_reliability.py +++ b/v1/supplier_reliability/supplier_reliability.py @@ -25,10 +25,12 @@ - **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. -Each constraint carries an entity back-pointer to what it grounds over -(``cap.supplier`` / ``meet.product``), just like a variable points back to its lane -(``qty_var.supplyorder``), so a marginal joins to that entity's own data by ENTITY -KEY (``cap.supplier.capacity``) -- never by parsing the constraint name string. +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: - Phase A: one baseline Problem solved with ``sensitivity=True``; the marginals are @@ -138,9 +140,9 @@ populate=False, ) -# Constraints, captured as handles and named per entity so each instance's marginal -# reads back through the constraint's ENTITY KEY (cap.supplier / meet.product), never -# by parsing the constraint name string. +# 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) @@ -149,6 +151,7 @@ <= Supplier.capacity ), name=["cap", Supplier.name], + keyed_by={"supplier": Supplier}, ) meet = baseline.satisfy( model.require( @@ -156,6 +159,7 @@ >= Product.demand ), name=["demand", Product.name], + keyed_by={"product": Product}, ) # Objective: minimize total sourcing cost. @@ -240,8 +244,9 @@ # at ~0. The strict converse holds only under a unique optimum. # --- Shadow prices: the marginal value of capacity and demand ------------------- -# A constraint carries an entity 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. +# 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"), From dca3276ed5867287ee87f7214ebd57d3d1b8233d Mon Sep 17 00:00:00 2001 From: Chris Coey Date: Wed, 3 Jun 2026 23:48:58 -0700 Subject: [PATCH 04/17] Clarify that IIS membership varies across solver builds The expected-output header claimed the conflict diagnosis is stable, but which six of the seven stranded jobs the IIS names is solver-dependent (observed across runs); only the statuses, costs, and binding cap are stable. Co-Authored-By: Claude Opus 4.8 --- v1/cicd_runner_allocation/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/v1/cicd_runner_allocation/README.md b/v1/cicd_runner_allocation/README.md index 957cd5c..e891847 100644 --- a/v1/cicd_runner_allocation/README.md +++ b/v1/cicd_runner_allocation/README.md @@ -91,7 +91,7 @@ Finally, it shows **conflict analysis (infeasibility diagnosis)**. A maintenance python cicd_runner_allocation.py ``` -6. Expected output (representative — equal-cost runners may be swapped between tied optima; the status, cost, and conflict diagnosis are stable): +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 -------------------------------------------------- From 3f0e0cef5bb83912908d0972c3c8a591aef8e87f Mon Sep 17 00:00:00 2001 From: Chris Coey Date: Thu, 4 Jun 2026 00:01:59 -0700 Subject: [PATCH 05/17] Scope the loop-warning filter to the solve builder The rules-created-in-a-loop false positive arises only while solve_allocation rebuilds the Problem per scenario, so suppress it with warnings.catch_warnings() inside that builder instead of mutating process-wide warning state at module scope. Warning behavior everywhere else in the run is now untouched. Co-Authored-By: Claude Opus 4.8 --- .../cicd_runner_allocation.py | 115 +++++++++--------- 1 file changed, 57 insertions(+), 58 deletions(-) diff --git a/v1/cicd_runner_allocation/cicd_runner_allocation.py b/v1/cicd_runner_allocation/cicd_runner_allocation.py index 06a5e35..96d61d2 100644 --- a/v1/cicd_runner_allocation/cicd_runner_allocation.py +++ b/v1/cicd_runner_allocation/cicd_runner_allocation.py @@ -39,15 +39,6 @@ from relationalai.semantics import Float, Integer, Model, String, sum from relationalai.semantics.reasoners.prescriptive import Problem -# This template re-solves the model once per scenario (the concurrency sweep) and again -# for the outage. Rebuilding the Problem -- with its per-workflow and per-runner *named* -# constraints -- in that loop trips PyRel's "rules created in a loop" heuristic. That -# 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 this one by -# message and leave every other warning visible. -warnings.filterwarnings("ignore", message=r"\[Rules created in a loop\]") - # -------------------------------------------------- # Configure inputs # -------------------------------------------------- @@ -156,60 +147,68 @@ def solve_allocation(concurrency_multiplier, offline_runners=(), conflict=False) 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. A maintenance - # outage drops the offline runners' assignments via where=. With no offline - # runners the comprehension is empty and `[] or None` collapses to None, i.e. - # "no filter" -- solve_for treats where=None and where=[] differently, so the - # None is deliberate. - 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, - ) + # 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, i.e. + # "no filter" -- solve_for treats where=None and where=[] differently, so the + # None is deliberate. + 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. ``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}, - ) + 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}, + ) - # 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}, - ) + # 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}, + ) - # Objective: minimize total pipeline cost. - problem.minimize( - sum( - Assignment.x_assigned - * Assignment.runner.cost_per_minute - * Assignment.workflow.estimated_minutes + # Objective: minimize total pipeline cost. + problem.minimize( + sum( + Assignment.x_assigned + * Assignment.runner.cost_per_minute + * Assignment.workflow.estimated_minutes + ) ) - ) - problem.solve("highs", time_limit_sec=60, conflict=conflict) - return Allocation(problem.solve_info(), assign_var, assign_one, conc) + problem.solve("highs", time_limit_sec=60, conflict=conflict) + return Allocation(problem.solve_info(), assign_var, assign_one, conc) def assignment_df(assign_var): From b0432a62130d0b01e09ae78e0aafef6f7571aefb Mon Sep 17 00:00:00 2001 From: Chris Coey Date: Thu, 4 Jun 2026 00:09:59 -0700 Subject: [PATCH 06/17] Correct compatibility and where-filter notes in the outage docs The two min_cpu=8 jobs are compatible with only ubuntu-xlarge and self-hosted-linux, so state the high-CPU compatibility claim as a bound rather than implying all seven jobs match all three big Linux runners. Restate the where= comment accurately: solve_for treats an empty where= and None identically. Note in the README why the template's else branch raises where the copyable snippet prints. Co-Authored-By: Claude Opus 4.8 --- v1/cicd_runner_allocation/README.md | 4 +++- v1/cicd_runner_allocation/cicd_runner_allocation.py | 11 ++++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/v1/cicd_runner_allocation/README.md b/v1/cicd_runner_allocation/README.md index e891847..b0454ad 100644 --- a/v1/cicd_runner_allocation/README.md +++ b/v1/cicd_runner_allocation/README.md @@ -314,7 +314,7 @@ for r in scenario_results: ### 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 `{ubuntu-large, ubuntu-xlarge, self-hosted-linux}`, 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: +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) @@ -331,6 +331,8 @@ else: 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 diff --git a/v1/cicd_runner_allocation/cicd_runner_allocation.py b/v1/cicd_runner_allocation/cicd_runner_allocation.py index 96d61d2..483a45a 100644 --- a/v1/cicd_runner_allocation/cicd_runner_allocation.py +++ b/v1/cicd_runner_allocation/cicd_runner_allocation.py @@ -162,9 +162,9 @@ def solve_allocation(concurrency_multiplier, offline_runners=(), conflict=False) # 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, i.e. - # "no filter" -- solve_for treats where=None and where=[] differently, so the - # None is deliberate. + # 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, @@ -229,8 +229,9 @@ def assignment_df(assign_var): # -------------------------------------------------- # Maintenance outage: take two well-connected Linux runners offline. Every high-CPU -# Linux job (min_cpu >= 4) is compatible only with {ubuntu-large, ubuntu-xlarge, -# self-hosted-linux}; with two of those three down, all seven funnel onto the one +# 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 = { From 591c9f10e9f573f50a2135e4e830e278a751088b Mon Sep 17 00:00:00 2001 From: Chris Coey Date: Thu, 4 Jun 2026 00:18:19 -0700 Subject: [PATCH 07/17] Refresh index/frontmatter and tighten assert diagnostics Regenerate v1/README.md to pick up the rewritten template descriptions; extend cicd_runner_allocation's description and experience level to cover the conflict-analysis addition. Add explanatory messages to the bare objective asserts so a data edit fails with expected-vs-got instead of a bare AssertionError, and drop a stray internal reference from a comment. Co-Authored-By: Claude Opus 4.8 --- v1/README.md | 6 +++--- v1/cicd_runner_allocation/README.md | 4 ++-- v1/cicd_runner_allocation/cicd_runner_allocation.py | 6 +++--- v1/factory_production/factory_production.py | 4 +++- v1/supplier_reliability/supplier_reliability.py | 9 +++++++-- 5 files changed, 18 insertions(+), 11 deletions(-) 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 b0454ad..5e428dd 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 diff --git a/v1/cicd_runner_allocation/cicd_runner_allocation.py b/v1/cicd_runner_allocation/cicd_runner_allocation.py index 483a45a..db8728c 100644 --- a/v1/cicd_runner_allocation/cicd_runner_allocation.py +++ b/v1/cicd_runner_allocation/cicd_runner_allocation.py @@ -364,9 +364,9 @@ def assignment_df(assign_var): 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 exercises - # in_conflict on the equality (== 1) rows -- the on-engine validation point for - # PyRel #1617. + # 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" diff --git a/v1/factory_production/factory_production.py b/v1/factory_production/factory_production.py index c6ed1c4..d8fc333 100644 --- a/v1/factory_production/factory_production.py +++ b/v1/factory_production/factory_production.py @@ -168,7 +168,9 @@ # 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 +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}") diff --git a/v1/supplier_reliability/supplier_reliability.py b/v1/supplier_reliability/supplier_reliability.py index e8e0fae..24c908f 100644 --- a/v1/supplier_reliability/supplier_reliability.py +++ b/v1/supplier_reliability/supplier_reliability.py @@ -174,7 +174,9 @@ 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 +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( @@ -370,7 +372,10 @@ print(f" Status: {si_scn.termination_status} — skipping results") continue print(f" Status: {si_scn.termination_status}, Objective: {si_scn.objective_value}") - assert abs(si_scn.objective_value - EXPECTED_SCENARIO_OBJECTIVE[label]) < 0.01 + assert 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}" + ) value_ref = Float.ref() qty_df = ( From 729f4bc5f4033cfdb9c9a78d13001d2f288eedb6 Mon Sep 17 00:00:00 2001 From: Chris Coey Date: Thu, 4 Jun 2026 00:23:07 -0700 Subject: [PATCH 08/17] Guard the outage runner names against runners.csv drift The maintenance-outage logic hard-codes the two offline runners and the surviving ubuntu-xlarge cap; if runners.csv renames any of them the where= exclusion silently excludes nothing and the outage stays feasible, failing later with an unrelated message. Mirror the existing workflow-side drift guard for the runner side, and note when the capacity-shadow-price requires get validated. Co-Authored-By: Claude Opus 4.8 --- v1/cicd_runner_allocation/cicd_runner_allocation.py | 7 +++++++ v1/supplier_reliability/supplier_reliability.py | 1 + 2 files changed, 8 insertions(+) diff --git a/v1/cicd_runner_allocation/cicd_runner_allocation.py b/v1/cicd_runner_allocation/cicd_runner_allocation.py index db8728c..d4f6386 100644 --- a/v1/cicd_runner_allocation/cicd_runner_allocation.py +++ b/v1/cicd_runner_allocation/cicd_runner_allocation.py @@ -252,6 +252,13 @@ def assignment_df(assign_var): "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__": diff --git a/v1/supplier_reliability/supplier_reliability.py b/v1/supplier_reliability/supplier_reliability.py index 24c908f..e2ea413 100644 --- a/v1/supplier_reliability/supplier_reliability.py +++ b/v1/supplier_reliability/supplier_reliability.py @@ -267,6 +267,7 @@ 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( From 538f2f632bbd9f86a27dc8e624e31653c29c7578 Mon Sep 17 00:00:00 2001 From: Chris Coey Date: Thu, 4 Jun 2026 00:26:47 -0700 Subject: [PATCH 09/17] Document row-identity and plan-pinning rationale in the runners Note why the complementary-slackness frame keeps the supplier column (select returns distinct rows, so an identifying column preserves one row per lane), why the baseline order quantities are not pinned (cost-equal alternate optima), and which Allocation fields serve the feasible vs infeasible read paths. Co-Authored-By: Claude Opus 4.8 --- v1/cicd_runner_allocation/cicd_runner_allocation.py | 5 +++-- v1/supplier_reliability/supplier_reliability.py | 9 +++++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/v1/cicd_runner_allocation/cicd_runner_allocation.py b/v1/cicd_runner_allocation/cicd_runner_allocation.py index d4f6386..7e883e0 100644 --- a/v1/cicd_runner_allocation/cicd_runner_allocation.py +++ b/v1/cicd_runner_allocation/cicd_runner_allocation.py @@ -135,8 +135,9 @@ # -------------------------------------------------- # Handles returned by a solve: the solve_info plus the variable and the two named -# constraint families, so callers can read assignments (feasible) or IIS membership -# (infeasible) by entity key. +# 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") diff --git a/v1/supplier_reliability/supplier_reliability.py b/v1/supplier_reliability/supplier_reliability.py index e2ea413..5ff02a3 100644 --- a/v1/supplier_reliability/supplier_reliability.py +++ b/v1/supplier_reliability/supplier_reliability.py @@ -185,7 +185,9 @@ # --- 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. +# 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( @@ -229,7 +231,10 @@ ) # (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: +# amount together (values(k, ref) is the solution accessor) and check in Python. The +# supplier column keeps one row per lane -- select returns DISTINCT rows, so without an +# identifying column, lanes with equal (reduced_cost, quantity) pairs would collapse -- +# and names the offender if the check ever fails: rc_amt = Float.ref() cs_df = ( model.select( From 62eef23ff670747aba2e6230b80291af27288174 Mon Sep 17 00:00:00 2001 From: Chris Coey Date: Thu, 4 Jun 2026 00:30:35 -0700 Subject: [PATCH 10/17] Keep the lane-identity comment backend-neutral State the practical reason for the supplier column (identifiable rows, names the offender on failure) without asserting projection semantics that differ across backends. Co-Authored-By: Claude Opus 4.8 --- v1/supplier_reliability/supplier_reliability.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/v1/supplier_reliability/supplier_reliability.py b/v1/supplier_reliability/supplier_reliability.py index 5ff02a3..ead79d5 100644 --- a/v1/supplier_reliability/supplier_reliability.py +++ b/v1/supplier_reliability/supplier_reliability.py @@ -232,9 +232,8 @@ # (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 one row per lane -- select returns DISTINCT rows, so without an -# identifying column, lanes with equal (reduced_cost, quantity) pairs would collapse -- -# and names the offender if the check ever fails: +# supplier column keeps each row identifiable -- and names the offender if the check +# ever fails: rc_amt = Float.ref() cs_df = ( model.select( From be256e5eb99e1d8567d689c8ca6c88da2b63fdbe Mon Sep 17 00:00:00 2001 From: Chris Coey Date: Thu, 4 Jun 2026 09:41:41 -0700 Subject: [PATCH 11/17] Sharpen sensitivity/conflict notes in template READMEs - State that marginal rates come without RHS/coefficient ranging, so the validity range is not reported - Note that conflict=True must be requested up front or on a fresh build; an already-solved Problem cannot add it on a re-solve - Simplify the LP/QP parenthetical in the supplier sensitivity note Co-Authored-By: Claude Opus 4.8 --- v1/cicd_runner_allocation/README.md | 2 +- v1/factory_production/README.md | 2 +- v1/supplier_reliability/README.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/v1/cicd_runner_allocation/README.md b/v1/cicd_runner_allocation/README.md index 5e428dd..ba50999 100644 --- a/v1/cicd_runner_allocation/README.md +++ b/v1/cicd_runner_allocation/README.md @@ -347,7 +347,7 @@ model.select(outage.conc.runner.name, outage.conc.runner.max_concurrent).where( 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. +> 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 diff --git a/v1/factory_production/README.md b/v1/factory_production/README.md index 6ed7402..5d28e28 100644 --- a/v1/factory_production/README.md +++ b/v1/factory_production/README.md @@ -215,7 +215,7 @@ model.select( ).inspect() ``` -Sensitivity marginals are exact for a linear program. They describe the rate of change at the current optimum, valid over a range; a large, discrete change (adding a factory, removing a product) is a structural change best answered by re-solving. +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 diff --git a/v1/supplier_reliability/README.md b/v1/supplier_reliability/README.md index d08dd54..64dc663 100644 --- a/v1/supplier_reliability/README.md +++ b/v1/supplier_reliability/README.md @@ -270,7 +270,7 @@ model.select( 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 (a binding mix of `<=`/`>=`/`=` 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. +> 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 From f2f70b7d103a4f507d4b4ee6a39754d83a36839c Mon Sep 17 00:00:00 2001 From: Chris Coey Date: Thu, 4 Jun 2026 11:41:25 -0700 Subject: [PATCH 12/17] Match demand shadow-price prose to the table order in supplier README Co-Authored-By: Claude Opus 4.8 --- v1/supplier_reliability/README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/v1/supplier_reliability/README.md b/v1/supplier_reliability/README.md index 64dc663..5ac289b 100644 --- a/v1/supplier_reliability/README.md +++ b/v1/supplier_reliability/README.md @@ -17,7 +17,7 @@ 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 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. +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. @@ -165,7 +165,8 @@ Finally, the template demonstrates **scenario analysis** by re-solving the probl 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 (`8`, `9`, `7`) are the marginal cost of one more unit of each product. + 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 From beaa6a9438cede0ff75ab6b41ef5738433fb9511 Mon Sep 17 00:00:00 2001 From: Chris Coey Date: Thu, 4 Jun 2026 12:00:28 -0700 Subject: [PATCH 13/17] Guard the scenario objective assert against a None objective Match the baseline assert's pattern so a missing objective fails as an AssertionError with the scenario named, not an opaque TypeError. Co-Authored-By: Claude Opus 4.8 --- v1/supplier_reliability/supplier_reliability.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/v1/supplier_reliability/supplier_reliability.py b/v1/supplier_reliability/supplier_reliability.py index ead79d5..6a28909 100644 --- a/v1/supplier_reliability/supplier_reliability.py +++ b/v1/supplier_reliability/supplier_reliability.py @@ -377,7 +377,10 @@ print(f" Status: {si_scn.termination_status} — skipping results") continue print(f" Status: {si_scn.termination_status}, Objective: {si_scn.objective_value}") - assert abs(si_scn.objective_value - EXPECTED_SCENARIO_OBJECTIVE[label]) < 0.01, ( + 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}" ) From eeab5bfd7bd8d06427c1c14e754cf1f7a94ab669 Mon Sep 17 00:00:00 2001 From: Chris Coey Date: Thu, 4 Jun 2026 12:08:22 -0700 Subject: [PATCH 14/17] Standardize the dash convention and sharpen the cross-template comparison Use the double-dash convention of the sibling templates throughout factory_production and the remaining cicd/supplier stragglers. Rewrite the factory<->supplier basis-status comparison so the supplier side names the priced-out lane role (NONBASIC_AT_LOWER) instead of reusing 'binding product', which maps to the wrong role in that template. Drop an unresolvable 'this portfolio' referent. Co-Authored-By: Claude Opus 4.8 --- v1/cicd_runner_allocation/README.md | 2 +- v1/factory_production/README.md | 28 +++++++++---------- v1/factory_production/factory_production.py | 2 +- v1/supplier_reliability/README.md | 2 +- .../supplier_reliability.py | 6 ++-- 5 files changed, 20 insertions(+), 20 deletions(-) diff --git a/v1/cicd_runner_allocation/README.md b/v1/cicd_runner_allocation/README.md index ba50999..ef65ef2 100644 --- a/v1/cicd_runner_allocation/README.md +++ b/v1/cicd_runner_allocation/README.md @@ -91,7 +91,7 @@ Finally, it shows **conflict analysis (infeasibility diagnosis)**. A maintenance python cicd_runner_allocation.py ``` -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): +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 -------------------------------------------------- diff --git a/v1/factory_production/README.md b/v1/factory_production/README.md index 5d28e28..1d3bbc0 100644 --- a/v1/factory_production/README.md +++ b/v1/factory_production/README.md @@ -18,23 +18,23 @@ tags: ## What this template is for -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**: the cleanest setting in this portfolio for learning what shadow prices and reduced costs mean. +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, bounded above by demand. A per-factory constraint keeps total resource usage within availability, and the objective maximizes total profit. -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: +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.) +- **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 — the binding product is `NONBASIC_AT_UPPER` (its demand cap) here, versus `NONBASIC_AT_LOWER` (zero) there. Laying the two reduced-cost tables side by side is the fastest way to internalize the conventions. +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 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. +> 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 @@ -133,7 +133,7 @@ Because the objective is a **maximization**, the *capacity shadow price* is the 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. + 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 @@ -200,7 +200,7 @@ problem.solve("highs", time_limit_sec=60, sensitivity=True) ### 4. Read the sensitivity marginals -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: +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? @@ -219,23 +219,23 @@ Sensitivity marginals are exact for a linear program. They describe the rate of ## Customize this template -- **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. +- **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. +- **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
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. +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 -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.) +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.)
diff --git a/v1/factory_production/factory_production.py b/v1/factory_production/factory_production.py index d8fc333..d1d8139 100644 --- a/v1/factory_production/factory_production.py +++ b/v1/factory_production/factory_production.py @@ -284,7 +284,7 @@ ) # -------------------------------------------------- -# Summary — capacity utilization per factory +# 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 diff --git a/v1/supplier_reliability/README.md b/v1/supplier_reliability/README.md index 5ac289b..473b18c 100644 --- a/v1/supplier_reliability/README.md +++ b/v1/supplier_reliability/README.md @@ -21,7 +21,7 @@ Procurement teams must choose which suppliers to source from when multiple optio 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. -A plain solve answers *"what is the cheapest sourcing plan?"*. This template also requests **sensitivity analysis** (`solve(sensitivity=True)`) on the baseline, which answers the *marginal* questions a planner asks next -- in the same solve: +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. diff --git a/v1/supplier_reliability/supplier_reliability.py b/v1/supplier_reliability/supplier_reliability.py index 6a28909..625de34 100644 --- a/v1/supplier_reliability/supplier_reliability.py +++ b/v1/supplier_reliability/supplier_reliability.py @@ -124,7 +124,7 @@ model.define(SupplyOrder.cost_per_unit(SupplyOption.cost_per_unit)).where(SupplyOrder.option(SupplyOption)) # -------------------------------------------------- -# Phase A — baseline solve with sensitivity analysis +# 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 @@ -308,7 +308,7 @@ ) # -------------------------------------------------- -# Phase B — disruption scenarios (separate re-solves) +# 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 @@ -374,7 +374,7 @@ } ) if si_scn.termination_status != "OPTIMAL": - print(f" Status: {si_scn.termination_status} — skipping results") + print(f" Status: {si_scn.termination_status} -- skipping results") continue print(f" Status: {si_scn.termination_status}, Objective: {si_scn.objective_value}") assert ( From 3ff6209b9ef705103654d9476226b9bdb414404d Mon Sep 17 00:00:00 2001 From: Chris Coey Date: Thu, 4 Jun 2026 12:20:45 -0700 Subject: [PATCH 15/17] Attribute the burst-mode saving to the low-CPU job migration The 1.5x narrative credited the saving to high-CPU jobs, but the 1.0x output already places all seven on self-hosted; the saving comes from the four low-CPU jobs that move off the ubuntu runners. Co-Authored-By: Claude Opus 4.8 --- v1/cicd_runner_allocation/README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/v1/cicd_runner_allocation/README.md b/v1/cicd_runner_allocation/README.md index ef65ef2..53fe4ad 100644 --- a/v1/cicd_runner_allocation/README.md +++ b/v1/cicd_runner_allocation/README.md @@ -171,7 +171,8 @@ Finally, it shows **conflict analysis (infeasibility diagnosis)**. A maintenance $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 keeping the high-CPU jobs off the pricier ubuntu-large and ubuntu-xlarge runners. (How the cheap, + $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.) From 05e231e4e4d349ea6951364ee2f1f68589a03389 Mon Sep 17 00:00:00 2001 From: Chris Coey Date: Thu, 4 Jun 2026 14:32:55 -0700 Subject: [PATCH 16/17] Pin relationalai 1.9.0; align README snippets and industry tag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Pin relationalai==1.9.0 — the first release that ships solve(sensitivity=) / solve(conflict=) — in the three pyprojects and README prerequisites. - Note under each README readback snippet that .inspect() is the quick interactive form; the scripts materialize the same selects with .to_df(). - supplier_reliability: industry frontmatter to "Supply Chain & Logistics", matching the portfolio convention. Co-Authored-By: Claude Opus 4.8 --- v1/cicd_runner_allocation/README.md | 4 +++- v1/cicd_runner_allocation/pyproject.toml | 2 +- v1/factory_production/README.md | 4 +++- v1/factory_production/pyproject.toml | 2 +- v1/supplier_reliability/README.md | 6 ++++-- v1/supplier_reliability/pyproject.toml | 2 +- 6 files changed, 13 insertions(+), 7 deletions(-) diff --git a/v1/cicd_runner_allocation/README.md b/v1/cicd_runner_allocation/README.md index 53fe4ad..fa30d3b 100644 --- a/v1/cicd_runner_allocation/README.md +++ b/v1/cicd_runner_allocation/README.md @@ -56,7 +56,7 @@ Finally, it shows **conflict analysis (infeasibility diagnosis)**. A maintenance ### Tools - Python >= 3.10 -- RelationalAI Python SDK (`relationalai`) >= 1.0.14 +- RelationalAI Python SDK (`relationalai`) >= 1.9.0 ## Quickstart @@ -345,6 +345,8 @@ model.select(outage.conc.runner.name, outage.conc.runner.max_concurrent).where( ).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] 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 1d3bbc0..f508325 100644 --- a/v1/factory_production/README.md +++ b/v1/factory_production/README.md @@ -65,7 +65,7 @@ Because the objective is a **maximization**, the *capacity shadow price* is the ### Tools - Python >= 3.10 -- RelationalAI Python SDK (`relationalai`) >= 1.0.14 +- RelationalAI Python SDK (`relationalai`) >= 1.9.0 ## Quickstart @@ -215,6 +215,8 @@ model.select( ).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 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 473b18c..b007f22 100644 --- a/v1/supplier_reliability/README.md +++ b/v1/supplier_reliability/README.md @@ -3,7 +3,7 @@ title: "Supplier 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: @@ -59,7 +59,7 @@ Finally, the template demonstrates **scenario analysis** by re-solving the probl ### Tools - Python >= 3.10 -- RelationalAI Python SDK (`relationalai`) >= 1.0.14 +- RelationalAI Python SDK (`relationalai`) >= 1.9.0 ## Quickstart @@ -268,6 +268,8 @@ model.select( ).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 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] 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", ] From dcc0d47ac80c4e53cebfb6d35c08f1bc7a5677dd Mon Sep 17 00:00:00 2001 From: Chris Coey Date: Thu, 4 Jun 2026 14:55:27 -0700 Subject: [PATCH 17/17] Capture expected outputs from a live end-to-end run All three templates now run end-to-end against the released sensitivity/conflict interface: each script exits 0 with all assertion guards holding. Statuses, objectives, shadow prices, reduced costs, and the conflict membership match the previous expected-output blocks exactly. The rows the READMEs hedge as tied-optima-dependent are updated to the captured solutions: the supplier baseline/scenario order splits and basis statuses, and the equal-cost ubuntu job split in the runner scenarios. Also name SupplierA among the suppliers absorbing demand in the without_SupplierC scenario. Co-Authored-By: Claude Opus 4.8 --- v1/cicd_runner_allocation/README.md | 12 ++++++------ v1/supplier_reliability/README.md | 18 +++++++++--------- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/v1/cicd_runner_allocation/README.md b/v1/cicd_runner_allocation/README.md index fa30d3b..42ff6be 100644 --- a/v1/cicd_runner_allocation/README.md +++ b/v1/cicd_runner_allocation/README.md @@ -102,9 +102,9 @@ Finally, it shows **conflict analysis (infeasibility diagnosis)**. A maintenance 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, build-frontend, 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): dependency-audit, 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 @@ -116,8 +116,8 @@ Finally, it shows **conflict analysis (infeasibility diagnosis)**. A maintenance 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): build-frontend, lint-and-format, release-notes - ubuntu-latest (5 jobs): dependency-audit, 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 @@ -129,8 +129,8 @@ Finally, it shows **conflict analysis (infeasibility diagnosis)**. A maintenance 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 (3 jobs): deploy-production, lint-and-format, release-notes - ubuntu-latest (1 jobs): deploy-staging + ubuntu-22.04 (2 jobs): deploy-production, release-notes + ubuntu-latest (2 jobs): deploy-staging, lint-and-format windows-latest (1 jobs): windows-installer ================================================== diff --git a/v1/supplier_reliability/README.md b/v1/supplier_reliability/README.md index b007f22..f774f11 100644 --- a/v1/supplier_reliability/README.md +++ b/v1/supplier_reliability/README.md @@ -100,18 +100,18 @@ Finally, the template demonstrates **scenario analysis** by re-solving the probl Baseline orders: supplier product quantity - SupplierB Widget 150.0 - SupplierC Component 200.0 + SupplierB Component 150.0 + SupplierC Component 50.0 SupplierC Gadget 250.0 - SupplierC Widget 150.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 NONBASIC_AT_LOWER + SupplierB Component 0.0 BASIC SupplierB Gadget 0.0 NONBASIC_AT_LOWER - SupplierB Widget 0.0 BASIC + SupplierB Widget 0.0 NONBASIC_AT_LOWER SupplierC Component 0.0 BASIC SupplierC Gadget 0.0 BASIC SupplierC Widget 0.0 BASIC @@ -139,9 +139,9 @@ Finally, the template demonstrates **scenario analysis** by re-solving the probl Orders: supplier product quantity SupplierA Widget 300.0 - SupplierB Component 150.0 - SupplierB Gadget 250.0 - SupplierD Component 50.0 + SupplierB Component 200.0 + SupplierB Gadget 200.0 + SupplierD Gadget 50.0 Running scenario: without_SupplierB Status: OPTIMAL, Objective: 5150.0 @@ -176,7 +176,7 @@ Finally, the template demonstrates **scenario analysis** by re-solving the probl 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 more expensive SupplierB and SupplierD -- consistent + $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.