diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 2b94f07143..e7dc5add53 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -50,6 +50,12 @@ "skills": "./", "description": "LP, MILP, and QP (beta) with cuOpt — CLI only (MPS files, cuopt_cli). Use when the user is solving LP, MILP, or QP from MPS via command line." }, + { + "name": "cuopt-multi-objective-exploration", + "source": "./skills/cuopt-multi-objective-exploration", + "skills": "./", + "description": "Trace and interpret the Pareto frontier across competing objectives using repeated single-objective cuOpt solves (weighted-sum and ε-constraint)." + }, { "name": "cuopt-routing-formulation", "source": "./skills/cuopt-routing-formulation", diff --git a/AGENTS.md b/AGENTS.md index f3e3f5625a..bbc3d09dde 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,7 @@ AI agent skills for NVIDIA cuOpt optimization engine. Skills live in **`skills/` ### Common (concepts only; no API code) - `skills/cuopt-numerical-optimization-formulation/` — LP / MILP / QP: concepts + problem parsing + common formulation patterns +- `skills/cuopt-multi-objective-exploration/` — Multi-objective: trace + interpret the Pareto frontier across competing objectives (ε-constraint / weighted-sum over repeated cuOpt solves) - `skills/cuopt-routing-formulation/` — Routing: VRP, TSP, PDP (problem types, data) - `skills/cuopt-server-common/` — Server: capabilities, workflow diff --git a/skills/cuopt-multi-objective-exploration/SKILL.md b/skills/cuopt-multi-objective-exploration/SKILL.md new file mode 100644 index 0000000000..cd22ab33dc --- /dev/null +++ b/skills/cuopt-multi-objective-exploration/SKILL.md @@ -0,0 +1,133 @@ +--- +name: cuopt-multi-objective-exploration +version: "26.08.00" +description: Trace and interpret the Pareto frontier across competing objectives using repeated single-objective cuOpt solves (weighted-sum and ε-constraint). +license: Apache-2.0 +origin: cuopt-skill-evolution +metadata: + author: NVIDIA cuOpt Team + tags: + - multi-objective + - pareto + - epsilon-constraint + - tradeoff + - workflow +--- + + +# Multi-Objective Exploration + +cuOpt optimizes **one** objective per solve. Many real problems have several objectives that pull against each other — cost vs. service level, return vs. risk, makespan vs. overtime, distance vs. vehicle count. A single solve answers "what's optimal *for one particular weighting*," but it hides the tradeoff the user actually needs to see. + +This skill turns a sequence of single-objective cuOpt solves into a **Pareto frontier** — the set of solutions where you can't improve one objective without giving up another — and gives the discipline to read it. It adds no solver features; it orchestrates the LP / MILP / QP solves already covered by the formulation and API skills. + +## When this applies + +Reach for this workflow when the problem has **two or more objectives with no agreed-upon weighting**, signalled by language like: + +- "balance X and Y", "trade off", "as cheap as possible *without* hurting service" +- "minimize cost *and* maximize coverage", "I want options, not one answer" +- any objective the user is willing to relax in exchange for another + +If there is a single clear objective (everything else is a hard constraint), this skill does not apply — formulate and solve once. + +## Core idea — one solve is one point on a curve + +A single optimum encodes **one implicit weighting** of the objectives. Change the weighting and the optimum moves. The frontier is the curve traced by all the non-dominated optima. + +A solution **A dominates** B when A is at least as good on every objective and strictly better on one. Dominated solutions are never worth choosing. The **Pareto frontier** is exactly the non-dominated set; the user's job is to pick a point on it, and yours is to show them the whole curve plus where the tradeoff is sharpest. + +Do not collapse a multi-objective problem to a single weighted number and report its optimum as "the answer" — that silently makes the tradeoff decision *for* the user. Trace the frontier and let them choose. + +Objectives and constraints are interchangeable. A requirement currently treated as fixed — a coverage floor, a fairness cap, a budget — is often a latent objective: its level was assumed, not given. Promoting such a constraint to a parametric ε-constraint and sweeping it reveals a tradeoff you'd otherwise hide, so read a single-objective model's hard constraints as candidate objectives, not just limits — but only when the level was an assumption. A genuinely fixed, non-negotiable limit (a hard budget cap, a regulatory minimum) stays a constraint; don't manufacture a tradeoff that isn't there. Express any promoted quantity linearly so it can serve as an ε-constraint (see `cuopt-numerical-optimization-formulation`). + +## Step 1 — define the objectives + +An informative frontier needs objectives that genuinely conflict: if they don't pull against each other, it collapses to a single point with nothing to trade off. And each objective has to be formulated correctly, since a wrong form, sense, or scale distorts the tradeoff and shifts where the knee falls. Formulate each one with `cuopt-numerical-optimization-formulation` before sweeping. + +## Step 2 — build a payoff table (anchor each objective) + +Solve each objective **on its own** first. For *k* objectives this is *k* solves. Record, for each, the value of every objective at that optimum: + +```text + f1 f2 f3 +min f1 → f1* f2(at f1*) f3(at f1*) +min f2 → ... f2* ... +min f3 → ... ... f3* +``` + +The diagonal (`f1*`, `f2*`, …) is each objective's best achievable value; the off-diagonals give the **range** each objective spans across the others' optima. This table does double duty: + +- It sets the **sweep bounds** for the ε-constraint method (the feasible range of each constrained objective). +- It supplies the **scales** for normalization — objectives in dollars, percent, and hours can't be weighted meaningfully until divided by their ranges. + +If any single-objective solve is already infeasible, stop and fix the model before sweeping — the frontier doesn't exist yet. + +## Step 3 — choose a scalarization + +### Weighted sum + +Combine the objectives into one and sweep the weights: + +```text +minimize w1·f1(x) + w2·f2(x) + ... , for a grid of weight vectors w +``` + +Cheap and trivial with any solver. Two limitations to respect: + +- **It only finds points on the convex hull of the frontier.** Concave (non-convex) regions of the frontier are unreachable no matter how you choose weights, and for MILP the reachable points can be sparse with large gaps. A frontier that looks suspiciously linear or has only a few clustered points is the symptom. +- **Weights are not priorities until the objectives are normalized.** Divide each `f_k` by its payoff-table range first; otherwise the largest-magnitude objective dominates regardless of intent. + +### ε-constraint (preferred for a complete frontier) + +Keep one objective; move the rest to constraints and sweep their right-hand sides: + +```text +minimize f1(x) +subject to f2(x) ≤ ε2 + f3(x) ≤ ε3 + (original constraints) +``` + +Sweep each `ε_k` across the range from the payoff table. Each `(ε2, ε3, …)` combination is a single standard cuOpt solve. This recovers the **full** frontier, including the concave regions weighted-sum cannot reach, which is why it's the default when completeness matters. The cost is more solves (a grid over the constrained objectives) and bookkeeping of the ε values. + +ε-constrain *linear* objectives directly. A quadratic objective (e.g. risk `xᵀΣx`) is simplest kept as the objective `f1` while you ε-constrain the linear ones. A **convex** quadratic objective *can* instead be ε-constrained directly: add it as a quadratic constraint `xᵀQx ≤ ε` (Q positive semidefinite, inequality only), which cuOpt routes through the barrier solver as a second-order cone. Non-convex or equality quadratic constraints are unsupported, and the MILP path stays linear-constraint only. + +Spot it in existing code: a hand-coded loop over a target or budget value (a return target, a cost cap) is already the ε-constraint method — name it as such, filter dominated points, and read the swept constraint's dual (LP/QP only). + +**Picking a method:** weighted-sum for a quick convex sketch or when you know the frontier is convex (e.g. a pure-LP/QP tradeoff); ε-constraint when the problem is MILP, when the frontier may be non-convex, or when the user needs a faithful and complete curve. + +## Step 4 — sweep, collect, and filter + +```text +frontier = [] +for each weight vector (or ε vector) in the grid: + set the combined objective (or ε right-hand sides) + solve with cuOpt # reuse the prior solution as a warm start + if status is Optimal/Feasible: + record (objective values, solution) +discard dominated and duplicate points +sort the survivors to form the frontier +``` + +Practical notes: + +- **Warm-start LP sweeps.** For an LP frontier, carry the previous solve's PDLP warmstart data into the next to cut solve time. Per cuOpt this is **LP-only**: a MILP solve doesn't take a PDLP warmstart (you can optionally seed a MIP start instead). See `cuopt-numerical-optimization-api-python` for the calls. +- **Cap each MILP solve.** Set a per-solve time limit on MILP sweeps (see `cuopt-numerical-optimization-api-python`) — a sweep is many solves, and branch-and-bound can over-spend certifying optimality past a tiny gap, while cuOpt sets no limit by default and won't warn. Report the points as optimal *to the gap you set*, not certified optimal. +- **Filter dominated points.** A correct sweep can still emit dominated points (especially weighted-sum near the hull, or MILP). Drop them; they are not part of the frontier. +- **Resolution is a budget.** Curve fidelity trades against solve count. Start coarse to see the shape, then refine the grid only where the curve bends. +- **Verify, don't assume.** When you claim one method beats another, measure it — e.g. count the efficient points ε-constraint recovered that weighted-sum missed — rather than asserting it; and flag any solve returning feasible-but-not-`Optimal` so a non-certified point is never read as exact. + +## Step 5 — interpret the frontier + +- **Report tradeoffs, not single numbers.** A frontier point means nothing in isolation. Quote the exchange rate — "≈ $4k of extra cost per 1% of added coverage in this region" — so the user can judge whether a move is worth it. +- **Flag knee points; don't auto-pick them.** The "knee" is where the curve bends most sharply — beyond it you pay a lot for a little. It's often the best-balanced compromise and worth highlighting, but the final choice is the user's preference, not a rule. +- **Treat dominated or gappy output as a diagnostic.** If dominated points survive filtering, or the frontier is implausibly sparse or perfectly linear, suspect the sweep or the model — most often weighted-sum hiding a concave region (switch to ε-constraint) or a normalization mistake. +- **State the weighting/ε you used.** Every reported point is conditional on its scalarization. Make that explicit so a single solve is never mistaken for "the" optimum. + +## Interfaces + +This skill is solver- and interface-agnostic. The per-solve mechanics — building the objective, adding the ε constraints, passing a warm start, reading status — live in the API skills: + +- `cuopt-numerical-optimization-api-python` / `-api-c` / `-api-cli` — LP, MILP, QP solves. +- `cuopt-routing-formulation` + `cuopt-routing-api-python` — the same frontier workflow applies to routing tradeoffs (distance vs. vehicles vs. time). diff --git a/skills/cuopt-multi-objective-exploration/evals/evals.json b/skills/cuopt-multi-objective-exploration/evals/evals.json new file mode 100644 index 0000000000..2e2bcef63c --- /dev/null +++ b/skills/cuopt-multi-objective-exploration/evals/evals.json @@ -0,0 +1,42 @@ +[ + { + "id": "multiobj-explore-eval-001-supplier-interpretation", + "question": "A procurement lead is sourcing a component. The candidate suppliers lie on a cost-vs-reliability tradeoff (cheaper tends to be less reliable): CN03 cost $7.05 reliability 81.1; SEA03 $7.63 / 82.6; LA04 $9.93 / 87.2; EU04 $11.29 / 88.2; NA01 $11.74 / 90.3; NA03 $12.33 / 91.0; NA04 $13.37 / 91.1. She asks: 'Which suppliers should we commit to?' Advise her.", + "expected_skill": "cuopt-multi-objective-exploration", + "expected_script": null, + "ground_truth": "The agent treats this as a two-objective tradeoff with no fixed weighting. It does NOT collapse to a single 'best' supplier; it lays out the cost/reliability tradeoff, quotes the exchange rate (e.g. roughly how much cost per reliability point between adjacent options), flags the knee/balanced region, states the assumption behind any option it highlights, and leaves the final pick to the lead (often a diversified mix rather than one supplier).", + "expected_behavior": [ + "Frames it as two competing objectives (cost vs reliability) with no agreed weighting", + "Does NOT declare one supplier as THE answer; preserves a genuine choice across options", + "Quantifies the tradeoff as an exchange rate (cost per unit of reliability, or vice versa)", + "Flags a knee / balanced region but leaves the final pick to the lead", + "States where on the tradeoff any specific option it names sits" + ] + }, + { + "id": "multiobj-explore-eval-002-supplier-exploration", + "question": "A procurement lead must choose which of 12 candidate suppliers to contract to maximize total supply-chain resilience and minimize total annual cost, while covering required demand. There is no agreed weighting between resilience and cost. Using cuOpt, how would you approach this and what would you report back?", + "expected_skill": "cuopt-multi-objective-exploration", + "expected_script": null, + "ground_truth": "The agent recognizes a multi-objective (resilience vs cost) selection problem with no fixed weighting and a hard demand constraint, builds a payoff table by solving each objective alone for ranges, then traces the Pareto frontier with repeated single-objective cuOpt solves. Because the supplier selection is combinatorial (non-convex), it prefers the epsilon-constraint method (e.g. minimize cost subject to a resilience floor, sweeping the floor) over weighted-sum, which would miss non-supported portfolios. It filters dominated points and reports the tradeoff curve plus the knee, deferring the final pick, and defers per-solve mechanics to the api-* skills and formulation to cuopt-numerical-optimization-formulation.", + "expected_behavior": [ + "Recognizes two competing objectives with no agreed weighting; does NOT collapse to one weighted optimum", + "Builds a payoff table (each objective alone) for ranges/normalization", + "Traces the Pareto frontier via repeated single-objective cuOpt solves; prefers epsilon-constraint over weighted-sum for completeness on a non-convex/MILP problem", + "Filters dominated and duplicate portfolios", + "Reports the tradeoff and flags the knee, leaving the final pick to the lead" + ] + }, + { + "id": "multiobj-explore-eval-003-single-objective-decoy", + "question": "A procurement lead must contract suppliers to MAXIMIZE total supply-chain resilience, but the annual budget is hard-capped: total cost must not exceed $34 (a firm limit, not negotiable), and the set must cover demand. Which suppliers should we contract?", + "expected_skill": null, + "expected_script": null, + "ground_truth": "DECOY (negative) — the multi-objective-exploration skill should NOT activate. There is a single clear objective (maximize resilience) with the cost as a hard constraint, so there is no tradeoff to explore. The correct response is a single optimization (maximize resilience subject to cost <= 34 and demand coverage) returning ONE recommended supplier set, not a Pareto sweep or a range of options.", + "expected_behavior": [ + "Recognizes a single objective with a hard constraint, not a tradeoff", + "Does NOT trace a frontier or sweep the budget as if it were a tradeoff dial", + "Returns one recommended supplier set (one solve), citing the binding budget and demand coverage" + ] + } +]