@ohtaman/washi 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ja.md +55 -20
- package/README.md +55 -20
- package/README.zh-CN.md +55 -20
- package/THIRD_PARTY_LICENSES.md +3 -4
- package/dist/browser/_washi-node-only-BvRk9kiK.js +0 -0
- package/dist/browser/browser-runtime-TLCY0hkc.js +1793 -0
- package/dist/browser/highs-worker.js +1 -1
- package/dist/browser/highs.wasm +0 -0
- package/dist/browser/highs.wasm.LICENSE +1 -2
- package/dist/browser/loader-DewlSho8.js +643 -0
- package/dist/browser/solvers/glpk.js +1 -1
- package/dist/browser/solvers/glpk.min.js +1 -1
- package/dist/browser/solvers/z3.js +7 -1
- package/dist/browser/solvers/z3.min.js +1 -1
- package/dist/browser/washi.js +1096 -444
- package/dist/browser/washi.min.js +3 -4
- package/dist/lib/_washi-node-only-BvRk9kiK.js +0 -0
- package/dist/lib/highs-browser-runtime.js +2120 -4124
- package/dist/lib/highs-node-runtime.js +24 -1
- package/dist/lib/highs-runtime.js +2 -0
- package/dist/lib/highs-worker.js +1 -1
- package/dist/lib/highs.wasm +0 -0
- package/dist/lib/highs.wasm.LICENSE +1 -2
- package/dist/lib/loader-BZUQNvh4.js +649 -0
- package/dist/lib/{registry-BEfvpZW3.js → registry-C20sxTwm.js} +54 -41
- package/dist/lib/solvers/glpk.js +2 -2
- package/dist/lib/solvers/z3.js +1 -1
- package/dist/lib/washi.js +973 -305
- package/dist/types/decomposition/benders.d.ts +32 -0
- package/dist/types/decomposition/benders.d.ts.map +1 -0
- package/dist/types/decomposition/column-generation.d.ts +34 -0
- package/dist/types/decomposition/column-generation.d.ts.map +1 -0
- package/dist/types/decomposition/index.d.ts +4 -0
- package/dist/types/decomposition/index.d.ts.map +1 -0
- package/dist/types/decomposition/types.d.ts +230 -0
- package/dist/types/decomposition/types.d.ts.map +1 -0
- package/dist/types/index.d.ts +4 -3
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/modeler/boolean-expr.d.ts.map +1 -1
- package/dist/types/modeler/constraint.d.ts +11 -0
- package/dist/types/modeler/constraint.d.ts.map +1 -1
- package/dist/types/modeler/expression.d.ts +10 -0
- package/dist/types/modeler/expression.d.ts.map +1 -1
- package/dist/types/modeler/index.d.ts +2 -1
- package/dist/types/modeler/index.d.ts.map +1 -1
- package/dist/types/modeler/linearizer.d.ts +15 -0
- package/dist/types/modeler/linearizer.d.ts.map +1 -0
- package/dist/types/modeler/lp-writer.d.ts +3 -1
- package/dist/types/modeler/lp-writer.d.ts.map +1 -1
- package/dist/types/modeler/model.d.ts +147 -2
- package/dist/types/modeler/model.d.ts.map +1 -1
- package/dist/types/modeler/solvers/highs/browser-runtime.d.ts +2 -1
- package/dist/types/modeler/solvers/highs/browser-runtime.d.ts.map +1 -1
- package/dist/types/modeler/solvers/highs/driver.d.ts +232 -0
- package/dist/types/modeler/solvers/highs/driver.d.ts.map +1 -0
- package/dist/types/modeler/solvers/highs/index.d.ts +1 -0
- package/dist/types/modeler/solvers/highs/index.d.ts.map +1 -1
- package/dist/types/modeler/solvers/highs/loader.d.ts +4 -4
- package/dist/types/modeler/solvers/highs/loader.d.ts.map +1 -1
- package/dist/types/modeler/solvers/highs/node-runtime.d.ts +2 -1
- package/dist/types/modeler/solvers/highs/node-runtime.d.ts.map +1 -1
- package/dist/types/modeler/solvers/highs/solver.d.ts +1 -1
- package/dist/types/modeler/solvers/highs/solver.d.ts.map +1 -1
- package/dist/types/modeler/solvers/types.d.ts +2 -0
- package/dist/types/modeler/solvers/types.d.ts.map +1 -1
- package/dist/types/modeler/types.d.ts +27 -0
- package/dist/types/modeler/types.d.ts.map +1 -1
- package/docs/examples/index.md +4 -0
- package/docs/examples/traveling-salesperson.md +173 -0
- package/docs/examples/vehicle-routing.md +206 -0
- package/docs/getting-started/ai-agents.md +8 -4
- package/docs/guide/constraints.md +1 -1
- package/docs/guide/decomposition.md +399 -0
- package/docs/guide/unsat-core.md +10 -3
- package/docs/ja/getting-started/ai-agents.md +8 -4
- package/docs/solvers/custom-solver.md +1 -1
- package/docs/solvers/highs.md +25 -3
- package/docs/solvers/index.md +3 -3
- package/docs/zh/getting-started/ai-agents.md +8 -4
- package/llms-full.txt +831 -15
- package/llms.txt +9 -5
- package/package.json +17 -14
- package/skills/washi/SKILL.md +8 -8
- package/dist/browser/browser-runtime-D13gHOT5.js +0 -4189
- package/dist/browser/loader-CtaDHef_.js +0 -273
- package/dist/lib/loader-DmNC_UNk.js +0 -279
package/llms-full.txt
CHANGED
|
@@ -3,7 +3,10 @@
|
|
|
3
3
|
> **Scope Note**: This document is the **consumer modeling quick-reference** for AI coding agents and developers building optimization or satisfiability models with `@ohtaman/washi`.
|
|
4
4
|
> - **Portable Agent Skill (`SKILL.md`)**: For an actionable end-to-end modeling, `toLpString()` inspection, `validateSolution()` verification, and Z3 `unsatCore` conflict-diagnosis workflow skill (compatible with Claude Code, Antigravity, Cursor, Codex, and other `SKILL.md`-aware agents), use [`skills/washi/SKILL.md`](https://github.com/ohtaman/washi/blob/main/skills/washi/SKILL.md) (also shipped in the npm package at `node_modules/@ohtaman/washi/skills/washi/SKILL.md`):
|
|
5
5
|
> ```bash
|
|
6
|
-
> #
|
|
6
|
+
> # Install directly via the skills CLI (skills.sh / vercel-labs/skills)
|
|
7
|
+
> npx skills add ohtaman/washi
|
|
8
|
+
>
|
|
9
|
+
> # Or link/copy the packaged skill into your project's agent skills directory
|
|
7
10
|
> mkdir -p .agents/skills/washi
|
|
8
11
|
> cp node_modules/@ohtaman/washi/skills/washi/SKILL.md .agents/skills/washi/SKILL.md
|
|
9
12
|
> ```
|
|
@@ -132,9 +135,10 @@ m.indicator(b, expr`${x} <= 50`, { activeValue: 1, name: "cap_when_b" });
|
|
|
132
135
|
- Returns `SolverStatus` (`"Optimal"`, `"Feasible"`, `"Infeasible"`, `"Unbounded"`, `"TimeLimit"`, `"IterationLimit"`, `"Error"`, `"Unknown"`, `"NotSolved"`, `"Sat"`, or `"Unsat"`). Built-in optimization solvers return the optimization statuses (`"Optimal"`, `"Feasible"`, `"Infeasible"`, `"Unbounded"`, `"TimeLimit"`, `"IterationLimit"`, `"Error"`, `"Unknown"`, `"NotSolved"`), where a time-, iteration-, or solution-limited run (or an interrupted solve) that found a valid primal incumbent returns `"Feasible"`; `"Sat"` and `"Unsat"` are included in `SolverStatus` for satisfiability/custom solver adapters (such as `MockSolver`).
|
|
133
136
|
- Read values via `x.value`, `expr`...`.value`, `m.getValues(x)`, `m.getValues(container)`, `m.objectiveValue`, `constr.dual`, `constr.slack`, and `x.reducedCost`.
|
|
134
137
|
- Call `m.validateSolution()` after solving to independently verify variable finiteness, variable bounds (`lb`/`ub`), integer/binary/boolean domains, linear constraint residuals, and logical constraints (`SolutionValidationReport`: `{ valid, complete, maxViolation, violations, ... }`). Note that `validateSolution()` checks primal feasibility of variables and constraints; it does not recompute or compare `m.objectiveValue`.
|
|
135
|
-
- **Satisfiability &
|
|
136
|
-
- Returns `CheckResult` (`{ status: "Sat" | "Unsat" | "Unknown", values: Record<string, number | boolean>, conflicts?: readonly string[], unsatCore?: readonly string[], runTime?: number }`), where `conflicts`
|
|
137
|
-
- For models containing
|
|
138
|
+
- **Satisfiability & Conflict Diagnosis (`await m.check(options?)`)**:
|
|
139
|
+
- Returns `CheckResult` (`{ status: "Sat" | "Unsat" | "Unknown", values: Record<string, number | boolean>, conflicts?: readonly string[], unsatCore?: readonly string[], runTime?: number }`), where `conflicts` is populated when the model is proven `"Unsat"`.
|
|
140
|
+
- For models containing linear arithmetic constraints or logical constraints (`BoolVar`, `and`, `or`, `implies`, `xor`), `m.check()` works with any solver (including the default HiGHS and GLPK via single-solve elastic slack relaxation, automatically linearizing logical constraints into 0-1 MIP formulation) and diagnoses conflicting constraints as `conflicts` (leaving `unsatCore: undefined` for semantic rigor).
|
|
141
|
+
- Exact SMT unsatisfiable-core extraction (`result.unsatCore` and `result.conflicts`) is provided when using an SMT-capable solver (`{ solver: "z3" }`). Because Z3 asserts variable bounds (`lb`/`ub`) and binary `{0, 1}` domains directly while tracking registered constraints, `unsatCore` reports a subset of registered constraint names that conflicts *together with the model's fixed variable bounds/domains* (and is not guaranteed minimal).
|
|
138
142
|
- **No concurrent mutation during async solve/check**:
|
|
139
143
|
- Mutating variables, constraints, or the objective while `await m.solve()` or `await m.check()` is pending invalidates the in-flight execution and rejects the stale promise.
|
|
140
144
|
|
|
@@ -1254,7 +1258,7 @@ m.constraint(and(or(isAdmin, hasMfa), not(xor(isAdmin, hasMfa))), "policy");
|
|
|
1254
1258
|
| :--- | :--- | :--- |
|
|
1255
1259
|
| **Trigger variable** | Numeric `Binary` variable (`m.binary()`) | `BoolVar` (`m.bool()`), `BoolOp`, or `Constraint` |
|
|
1256
1260
|
| **Consequent** | Linear `Constraint` (`<=`, `>=`, `==`) | `BoolVar`, `BoolOp`, or linear `Constraint` |
|
|
1257
|
-
| **Target Solvers** | **All MIP/LP solvers** (HiGHS, GLPK, Z3) via Big-M linearization | **
|
|
1261
|
+
| **Target Solvers** | **All MIP/LP solvers** (HiGHS, GLPK, Z3) via Big-M linearization | **All solvers** (HiGHS and GLPK via automatic 0-1 MIP linearization, Z3 natively) |
|
|
1258
1262
|
| **Best For** | Operations research & MIP optimization with an objective (`m.solve()`) | Rule engines, policy verification, and SMT feasibility (`m.check()`) |
|
|
1259
1263
|
|
|
1260
1264
|
---
|
|
@@ -1495,16 +1499,23 @@ Notice that `routine_operating_cost` is **not** in the core, because only `minim
|
|
|
1495
1499
|
> - **Use `m.check({ solver: "z3" })` for Unsat Core extraction**: `m.solve()` runs optimization (`Z3.Optimize`), whereas `m.check()` runs tracked satisfiability checking (`Z3.Solver.assertAndTrack`) and populates `result.conflicts` / `m.conflicts` (and alias `m.unsatCore`).
|
|
1496
1500
|
> - **Name your constraints**: Variable bounds (`lb`, `ub`) act as background domain declarations. To include a bound or business rule in the reported `unsatCore` list, register it as a named constraint via `m.constraint(expr`...`, "rule_name")`.
|
|
1497
1501
|
|
|
1498
|
-
### Workflow:
|
|
1502
|
+
### Workflow: Infeasibility Diagnosis with HiGHS or Z3
|
|
1499
1503
|
|
|
1500
|
-
Because WaSHI models are solver-independent, you can
|
|
1504
|
+
Because WaSHI models are solver-independent, you can diagnose infeasibilities using either solver:
|
|
1505
|
+
1. **Built-in HiGHS & GLPK (default)**: `await m.check()` uses a single-solve elastic slack formulation ($\min \sum s_i$) to identify which constraints required relaxation (`diag.conflicts`), requiring no extra dependencies.
|
|
1506
|
+
2. **Z3 SMT Solver**: `await m.check({ solver: "z3" })` uses SMT tracking to extract an exact unsatisfiable core (`diag.unsatCore` / `diag.conflicts`).
|
|
1501
1507
|
|
|
1502
1508
|
```typescript
|
|
1503
1509
|
const status = await m.solve(); // Fast MIP solve with default HiGHS
|
|
1504
1510
|
|
|
1505
1511
|
if (status === "Infeasible") {
|
|
1506
|
-
|
|
1512
|
+
// Option 1: Built-in single-solve elastic diagnosis with HiGHS
|
|
1513
|
+
const diag = await m.check();
|
|
1507
1514
|
console.error("Model is infeasible due to conflicting rules:", diag.conflicts);
|
|
1515
|
+
|
|
1516
|
+
// Option 2: Exact SMT unsat core diagnosis with Z3 (requires import "@ohtaman/washi/solvers/z3")
|
|
1517
|
+
// const z3Diag = await m.check({ solver: "z3" });
|
|
1518
|
+
// console.error("Z3 Unsat Core:", z3Diag.unsatCore);
|
|
1508
1519
|
}
|
|
1509
1520
|
```
|
|
1510
1521
|
|
|
@@ -1601,6 +1612,408 @@ console.log(x.value, y.value); // 10, 20
|
|
|
1601
1612
|
|
|
1602
1613
|
---
|
|
1603
1614
|
|
|
1615
|
+
# Advanced Decomposition Methods & Best Practices
|
|
1616
|
+
|
|
1617
|
+
Mixed-Integer Programming (MIP) formulations can become slow when symmetry or coupling constraints cause branch-and-bound trees to grow large.
|
|
1618
|
+
WaSHI provides decomposition orchestrators (`master.solveColumnGeneration` and `master.solveBenders`) along with lower-level modeling primitives for dual inspection and dynamic model modification.
|
|
1619
|
+
|
|
1620
|
+
---
|
|
1621
|
+
|
|
1622
|
+
## Why Decomposition? (When Monolithic MIP Stalls)
|
|
1623
|
+
|
|
1624
|
+
There are two common problem structures where monolithic MIP formulations struggle:
|
|
1625
|
+
|
|
1626
|
+
| Problem Structure | Monolithic MIP Bottleneck | Technique | WaSHI API |
|
|
1627
|
+
| :--- | :--- | :--- | :--- |
|
|
1628
|
+
| **Symmetry & Loose Continuous Bounds**<br>*(Cutting Stock, Vehicle Routing/VRP, Crew Rostering)* | Symmetric permutations and a weak continuous relaxation enlarge the branch-and-bound tree. | **Column Generation**<br>*(Dantzig-Wolfe Decomposition)* | [`master.solveColumnGeneration()`](#column-generation-solvecolumngeneration) |
|
|
1629
|
+
| **Complicating Decisions & Multi-Stage Structure**<br>*(Traveling Salesperson/TSP, Facility Location with Routing, Two-Stage Stochastic Planning)* | Coupling master decisions with large scenario or operational subproblems increases the matrix size. | **Benders Decomposition** | [`master.solveBenders()`](#benders-decomposition-solvebenders) |
|
|
1630
|
+
|
|
1631
|
+
---
|
|
1632
|
+
|
|
1633
|
+
## Foundational Primitives
|
|
1634
|
+
|
|
1635
|
+
WaSHI provides fine-grained modeling primitives to inspect dual information and dynamically modify models:
|
|
1636
|
+
|
|
1637
|
+
### 1. Constraint Duals (`dual`)
|
|
1638
|
+
Access the dual values (marginal impact on the objective per unit change in RHS) after an LP solve:
|
|
1639
|
+
|
|
1640
|
+
```typescript
|
|
1641
|
+
m.constraint(expr`${x} + ${y} <= 10`, "capacity");
|
|
1642
|
+
await m.solve();
|
|
1643
|
+
|
|
1644
|
+
const c = m.getConstraintByName("capacity")!;
|
|
1645
|
+
console.log(c.dual); // Dual value / shadow price
|
|
1646
|
+
console.log(m.solution?.duals.capacity);
|
|
1647
|
+
```
|
|
1648
|
+
|
|
1649
|
+
### 2. Continuous LP Relaxation (`model.relax()`)
|
|
1650
|
+
Decomposition algorithms require solving the continuous LP relaxation to extract dual values. `model.relax()` temporarily relaxes all `Integer` and `Binary` variables to continuous variables within their bounds:
|
|
1651
|
+
|
|
1652
|
+
```typescript
|
|
1653
|
+
// Solves LP relaxation and populates shadow prices in m.solution.duals
|
|
1654
|
+
const status = await m.relax();
|
|
1655
|
+
console.log("Theoretical dual bound:", m.objectiveValue);
|
|
1656
|
+
```
|
|
1657
|
+
|
|
1658
|
+
### 3. Dynamic Column Injection (`model.addColumn()`)
|
|
1659
|
+
Inject a new decision variable along with its objective coefficient and constraint contributions in a single call:
|
|
1660
|
+
|
|
1661
|
+
```typescript
|
|
1662
|
+
const col = m.addColumn({
|
|
1663
|
+
name: "pattern_1",
|
|
1664
|
+
type: "Integer", // or "Continuous"
|
|
1665
|
+
lb: 0,
|
|
1666
|
+
cost: 1, // objective coefficient
|
|
1667
|
+
coefficients: {
|
|
1668
|
+
demand_itemA: 2,
|
|
1669
|
+
demand_itemB: 1,
|
|
1670
|
+
},
|
|
1671
|
+
});
|
|
1672
|
+
```
|
|
1673
|
+
|
|
1674
|
+
### 4. Dynamic Constraint Modification (`addTerm` / `setTerm`)
|
|
1675
|
+
Update the coefficients of individual variables in an existing constraint:
|
|
1676
|
+
|
|
1677
|
+
```typescript
|
|
1678
|
+
const c = m.getConstraintByName("budget")!;
|
|
1679
|
+
c.addTerm(newVar, 5); // Increments newVar's coefficient by 5
|
|
1680
|
+
c.setTerm(newVar, 0); // Setting to 0 cleanly removes the term
|
|
1681
|
+
```
|
|
1682
|
+
|
|
1683
|
+
---
|
|
1684
|
+
|
|
1685
|
+
## Column Generation (`solveColumnGeneration`)
|
|
1686
|
+
|
|
1687
|
+
Column Generation starts with a **Restricted Master Problem (RMP)** with a small subset of columns. In each iteration:
|
|
1688
|
+
1. It solves the RMP's LP relaxation via `master.relax()`.
|
|
1689
|
+
2. Evaluates a pricing subproblem using constraint shadow prices.
|
|
1690
|
+
3. Finds candidate columns with negative reduced costs ($\bar{c} = \text{cost} - \sum \pi_i a_i < 0$) and dynamically injects them into the master problem.
|
|
1691
|
+
4. Repeats until no improving columns exist.
|
|
1692
|
+
5. Finally solves the full master model as a Mixed-Integer Program (`finalizeAsMIP`, default: `true`) to obtain optimal integer solutions.
|
|
1693
|
+
|
|
1694
|
+
### Column Generation with the `pricing` Callback
|
|
1695
|
+
|
|
1696
|
+
Provide a `pricing` callback that receives constraint shadow prices (duals), solves the subproblem (using a WaSHI model or any custom algorithm), and returns new column proposals. WaSHI automatically evaluates reduced costs and injects improving columns:
|
|
1697
|
+
|
|
1698
|
+
```typescript
|
|
1699
|
+
import { Model, expr, minimize, maximize } from "@ohtaman/washi";
|
|
1700
|
+
|
|
1701
|
+
const rollWidth = 100;
|
|
1702
|
+
const items = [
|
|
1703
|
+
{ id: "A", width: 45, demand: 10 },
|
|
1704
|
+
{ id: "B", width: 36, demand: 14 },
|
|
1705
|
+
{ id: "C", width: 16, demand: 20 },
|
|
1706
|
+
];
|
|
1707
|
+
|
|
1708
|
+
// 1. Define Restricted Master Problem (RMP) with naive initial single-cut patterns
|
|
1709
|
+
const master = new Model("CuttingStockMaster");
|
|
1710
|
+
|
|
1711
|
+
const pA = master.integer("init_A", { lb: 0 }); // 2 of A per roll (45*2 = 90 <= 100)
|
|
1712
|
+
const pB = master.integer("init_B", { lb: 0 }); // 2 of B per roll (36*2 = 72 <= 100)
|
|
1713
|
+
const pC = master.integer("init_C", { lb: 0 }); // 6 of C per roll (16*6 = 96 <= 100)
|
|
1714
|
+
|
|
1715
|
+
master.objective(minimize(expr`${pA} + ${pB} + ${pC}`));
|
|
1716
|
+
master.constraint(expr`2 * ${pA} >= 10`, "demand_A");
|
|
1717
|
+
master.constraint(expr`2 * ${pB} >= 14`, "demand_B");
|
|
1718
|
+
master.constraint(expr`6 * ${pC} >= 20`, "demand_C");
|
|
1719
|
+
const [demandA, demandB, demandC] = master.constraints;
|
|
1720
|
+
|
|
1721
|
+
// 2. Solve with master.solveColumnGeneration()
|
|
1722
|
+
const result = await master.solveColumnGeneration({
|
|
1723
|
+
finalizeAsMIP: true,
|
|
1724
|
+
pricing: async () => {
|
|
1725
|
+
// Pricing subproblem: Bounded Knapsack
|
|
1726
|
+
const sub = new Model("KnapsackPricing");
|
|
1727
|
+
const a_A = sub.integer("a_A", { lb: 0, ub: 2 });
|
|
1728
|
+
const a_B = sub.integer("a_B", { lb: 0, ub: 2 });
|
|
1729
|
+
const a_C = sub.integer("a_C", { lb: 0, ub: 6 });
|
|
1730
|
+
|
|
1731
|
+
// Maximize shadow price collection
|
|
1732
|
+
const dualA = demandA.dual ?? 0;
|
|
1733
|
+
const dualB = demandB.dual ?? 0;
|
|
1734
|
+
const dualC = demandC.dual ?? 0;
|
|
1735
|
+
sub.objective(maximize(expr`${dualA} * ${a_A} + ${dualB} * ${a_B} + ${dualC} * ${a_C}`));
|
|
1736
|
+
sub.constraint(expr`45 * ${a_A} + 36 * ${a_B} + 16 * ${a_C} <= ${rollWidth}`);
|
|
1737
|
+
|
|
1738
|
+
await sub.solve();
|
|
1739
|
+
|
|
1740
|
+
// Return the generated cutting pattern
|
|
1741
|
+
return {
|
|
1742
|
+
cost: 1,
|
|
1743
|
+
type: "Integer",
|
|
1744
|
+
coefficients: {
|
|
1745
|
+
demand_A: Math.round(a_A.value ?? 0),
|
|
1746
|
+
demand_B: Math.round(a_B.value ?? 0),
|
|
1747
|
+
demand_C: Math.round(a_C.value ?? 0),
|
|
1748
|
+
},
|
|
1749
|
+
};
|
|
1750
|
+
},
|
|
1751
|
+
});
|
|
1752
|
+
|
|
1753
|
+
console.log("Optimal rolls needed:", result.objectiveValue); // 14
|
|
1754
|
+
console.log("Iterations executed:", result.iterations);
|
|
1755
|
+
console.log("Dynamic columns added:", result.columnsAdded);
|
|
1756
|
+
```
|
|
1757
|
+
|
|
1758
|
+
### Custom Pricing Algorithms (Graph / Dynamic Programming)
|
|
1759
|
+
|
|
1760
|
+
Because `pricing` is a standard callback returning column definitions, you can solve the subproblem using any algorithm (e.g. Dijkstra, Bellman-Ford, or custom heuristics):
|
|
1761
|
+
|
|
1762
|
+
```typescript
|
|
1763
|
+
const result = await master.solveColumnGeneration({
|
|
1764
|
+
pricing: async ({ getDual }) => {
|
|
1765
|
+
// Solve subproblem with specialized custom graph algorithm
|
|
1766
|
+
const route = solveSpecializedShortestPath(getDual);
|
|
1767
|
+
if (!route) return null;
|
|
1768
|
+
|
|
1769
|
+
return {
|
|
1770
|
+
cost: route.distance,
|
|
1771
|
+
type: "Integer",
|
|
1772
|
+
coefficients: route.visitedCustomers,
|
|
1773
|
+
};
|
|
1774
|
+
},
|
|
1775
|
+
});
|
|
1776
|
+
```
|
|
1777
|
+
|
|
1778
|
+
---
|
|
1779
|
+
|
|
1780
|
+
## Benders Decomposition (`solveBenders`)
|
|
1781
|
+
|
|
1782
|
+
Benders Decomposition partitions the problem into:
|
|
1783
|
+
- **Master Problem**: Contains complicating variables (e.g., 0-1 facility opening decisions $y$) and an auxiliary variable $\theta \ge 0$ representing estimated subproblem costs.
|
|
1784
|
+
- **Subproblem**: Solves operational decisions given fixed master values $y^*$, feeding back **Optimality Cuts** to the master.
|
|
1785
|
+
|
|
1786
|
+
### Recommended: Automated Cut Generation with `sub.link()`
|
|
1787
|
+
|
|
1788
|
+
Return the subproblem `Model` from your callback and register linking constraints with `sub.link(...)`. WaSHI automatically manages $\theta$, evaluates master candidate values, extracts duals, and injects Benders optimality cuts:
|
|
1789
|
+
|
|
1790
|
+
```typescript
|
|
1791
|
+
import { Model, expr, minimize } from "@ohtaman/washi";
|
|
1792
|
+
|
|
1793
|
+
// 1. Master problem: Facility location decisions
|
|
1794
|
+
const master = new Model("FacilityMaster");
|
|
1795
|
+
const y1 = master.binary("open_F1");
|
|
1796
|
+
const y2 = master.binary("open_F2");
|
|
1797
|
+
|
|
1798
|
+
// Objective: Fixed facility costs (theta is managed automatically)
|
|
1799
|
+
master.objective(minimize(expr`100 * ${y1} + 120 * ${y2}`));
|
|
1800
|
+
// Minimal aggregate capacity requirement
|
|
1801
|
+
master.constraint(expr`50 * ${y1} + 60 * ${y2} >= 75`, "capacity_check");
|
|
1802
|
+
|
|
1803
|
+
// 2. Solve automatically with master.solveBenders()
|
|
1804
|
+
const result = await master.solveBenders({
|
|
1805
|
+
gapTolerance: 1e-4,
|
|
1806
|
+
subproblem: () => {
|
|
1807
|
+
// Operational dispatch subproblem
|
|
1808
|
+
const sub = new Model("TransportSubproblem");
|
|
1809
|
+
const x11 = sub.continuous("x_11", { lb: 0 });
|
|
1810
|
+
const x12 = sub.continuous("x_12", { lb: 0 });
|
|
1811
|
+
const x13 = sub.continuous("x_13", { lb: 0 });
|
|
1812
|
+
const x21 = sub.continuous("x_21", { lb: 0 });
|
|
1813
|
+
const x22 = sub.continuous("x_22", { lb: 0 });
|
|
1814
|
+
const x23 = sub.continuous("x_23", { lb: 0 });
|
|
1815
|
+
|
|
1816
|
+
sub.objective(
|
|
1817
|
+
minimize(
|
|
1818
|
+
expr`3 * ${x11} + 4 * ${x12} + 2 * ${x13} + 4 * ${x21} + 2 * ${x22} + 3 * ${x23}`
|
|
1819
|
+
)
|
|
1820
|
+
);
|
|
1821
|
+
|
|
1822
|
+
// Customer demand constraints
|
|
1823
|
+
sub.constraint(expr`${x11} + ${x21} >= 20`, "demand_C1");
|
|
1824
|
+
sub.constraint(expr`${x12} + ${x22} >= 25`, "demand_C2");
|
|
1825
|
+
sub.constraint(expr`${x13} + ${x23} >= 30`, "demand_C3");
|
|
1826
|
+
|
|
1827
|
+
// Link subproblem flows with master decisions y1, y2 via linking constraints
|
|
1828
|
+
sub.link(expr`${x11} + ${x12} + ${x13} <= 50 * ${y1}`);
|
|
1829
|
+
sub.link(expr`${x21} + ${x22} + ${x23} <= 60 * ${y2}`);
|
|
1830
|
+
|
|
1831
|
+
return sub; // Returning the model automatically generates cuts and manages theta
|
|
1832
|
+
},
|
|
1833
|
+
});
|
|
1834
|
+
|
|
1835
|
+
console.log("Optimal total cost:", result.objectiveValue); // 390
|
|
1836
|
+
console.log("Iterations:", result.iterations);
|
|
1837
|
+
console.log("Cuts added:", result.cutsAdded);
|
|
1838
|
+
console.log("Facility decisions:", {
|
|
1839
|
+
F1: result.values.open_F1,
|
|
1840
|
+
F2: result.values.open_F2,
|
|
1841
|
+
});
|
|
1842
|
+
```
|
|
1843
|
+
|
|
1844
|
+
### Low-Level Control: Custom `BendersCut` Callback
|
|
1845
|
+
|
|
1846
|
+
You can also construct Benders cuts manually by returning `BendersCut` objects:
|
|
1847
|
+
|
|
1848
|
+
```typescript
|
|
1849
|
+
const result = await master.solveBenders({
|
|
1850
|
+
gapTolerance: 1e-4,
|
|
1851
|
+
subproblem: async () => {
|
|
1852
|
+
const open1 = y1.value ?? 0;
|
|
1853
|
+
const open2 = y2.value ?? 0;
|
|
1854
|
+
const estTheta = theta.value ?? 0;
|
|
1855
|
+
|
|
1856
|
+
const sub = new Model("TransportSubproblem");
|
|
1857
|
+
// ...
|
|
1858
|
+
await sub.solve();
|
|
1859
|
+
|
|
1860
|
+
const actualCost = sub.objectiveValue!;
|
|
1861
|
+
if (estTheta < actualCost - 1e-4) {
|
|
1862
|
+
return {
|
|
1863
|
+
kind: "optimality",
|
|
1864
|
+
constraint: expr`${theta} - 50 * ${sub.solution!.duals.cap1} * ${y1} - 60 * ${sub.solution!.duals.cap2} * ${y2} >= ${rhs}`,
|
|
1865
|
+
subproblemObjective: 100 * open1 + 120 * open2 + actualCost,
|
|
1866
|
+
};
|
|
1867
|
+
}
|
|
1868
|
+
return null; // Converged
|
|
1869
|
+
},
|
|
1870
|
+
});
|
|
1871
|
+
```
|
|
1872
|
+
|
|
1873
|
+
---
|
|
1874
|
+
|
|
1875
|
+
## Application Examples
|
|
1876
|
+
|
|
1877
|
+
Two common applications of decomposition methods:
|
|
1878
|
+
|
|
1879
|
+
### 1. Vehicle Routing Problem (CVRP) $\times$ Column Generation
|
|
1880
|
+
*(Full executable source: [`examples/node/13-vehicle-routing-column-generation.ts`](https://github.com/ohtaman/washi/blob/main/examples/node/13-vehicle-routing-column-generation.ts))*
|
|
1881
|
+
|
|
1882
|
+
* **Problem Structure**: Enumerating every feasible customer tour upfront produces a large number of variables.
|
|
1883
|
+
* **Column Generation Formulation**:
|
|
1884
|
+
* **Master Problem**: Set covering model that selects the minimum number of vehicle routes such that each customer is served.
|
|
1885
|
+
* **Pricing Subproblem**: Knapsack model that dynamically generates a capacity-feasible route (column) with negative reduced cost based on customer dual shadow prices.
|
|
1886
|
+
|
|
1887
|
+
```typescript
|
|
1888
|
+
import { Model, expr, minimize, LinExpr, type Var } from "@ohtaman/washi";
|
|
1889
|
+
|
|
1890
|
+
const vehicleCapacity = 8;
|
|
1891
|
+
const customers = [
|
|
1892
|
+
{ id: "C1", demand: 4 },
|
|
1893
|
+
{ id: "C2", demand: 3 },
|
|
1894
|
+
{ id: "C3", demand: 5 },
|
|
1895
|
+
{ id: "C4", demand: 2 },
|
|
1896
|
+
{ id: "C5", demand: 4 },
|
|
1897
|
+
];
|
|
1898
|
+
|
|
1899
|
+
// 1. Master Problem: Customer coverage
|
|
1900
|
+
const master = new Model("CVRP_Master");
|
|
1901
|
+
const initialRoutes: Var[] = [];
|
|
1902
|
+
for (const cust of customers) {
|
|
1903
|
+
initialRoutes.push(master.integer(`init_${cust.id}`, { lb: 0, ub: 1 }));
|
|
1904
|
+
}
|
|
1905
|
+
master.objective(minimize(initialRoutes.reduce((acc, r) => acc.add(r), new LinExpr())));
|
|
1906
|
+
|
|
1907
|
+
for (let i = 0; i < customers.length; i++) {
|
|
1908
|
+
master.constraint(expr`${initialRoutes[i]} >= 1`, `visit_${customers[i].id}`);
|
|
1909
|
+
}
|
|
1910
|
+
const visitConstrs = master.constraints;
|
|
1911
|
+
|
|
1912
|
+
// 2. Solve via Column Generation
|
|
1913
|
+
const result = await master.solveColumnGeneration({
|
|
1914
|
+
finalizeAsMIP: true,
|
|
1915
|
+
pricing: async () => {
|
|
1916
|
+
const sub = new Model("RoutePricing");
|
|
1917
|
+
const a: Record<string, Var> = {};
|
|
1918
|
+
const obj = new LinExpr();
|
|
1919
|
+
|
|
1920
|
+
for (let i = 0; i < customers.length; i++) {
|
|
1921
|
+
const cust = customers[i];
|
|
1922
|
+
a[cust.id] = sub.binary(`v_${cust.id}`);
|
|
1923
|
+
obj.addTerm(a[cust.id], visitConstrs[i].dual ?? 0);
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
sub.objective(maximize(obj));
|
|
1927
|
+
|
|
1928
|
+
// Capacity limit: sum(demand_i * a_i) <= vehicleCapacity
|
|
1929
|
+
const load = customers.reduce((acc, c) => acc.add(a[c.id].mul(c.demand)), new LinExpr());
|
|
1930
|
+
sub.constraint(expr`${load} <= ${vehicleCapacity}`);
|
|
1931
|
+
|
|
1932
|
+
await sub.solve();
|
|
1933
|
+
|
|
1934
|
+
const coefficients: Record<string, number> = {};
|
|
1935
|
+
for (const cust of customers) {
|
|
1936
|
+
coefficients[`visit_${cust.id}`] = Math.round(a[cust.id].value ?? 0);
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
return {
|
|
1940
|
+
cost: 1,
|
|
1941
|
+
type: "Integer",
|
|
1942
|
+
coefficients,
|
|
1943
|
+
};
|
|
1944
|
+
},
|
|
1945
|
+
});
|
|
1946
|
+
|
|
1947
|
+
console.log("Optimal fleet size:", result.objectiveValue); // 3 vehicles
|
|
1948
|
+
```
|
|
1949
|
+
|
|
1950
|
+
### 2. Traveling Salesperson Problem (TSP) $\times$ Benders Decomposition (Lazy Subtour Elimination)
|
|
1951
|
+
*(Full executable source: [`examples/node/14-traveling-salesperson-benders.ts`](https://github.com/ohtaman/washi/blob/main/examples/node/14-traveling-salesperson-benders.ts))*
|
|
1952
|
+
|
|
1953
|
+
* **Problem Structure**: Preventing isolated sub-loops (subtours) requires Subtour Elimination Constraints (SECs), which grow as $O(2^n)$ constraints for $n$ cities.
|
|
1954
|
+
* **Benders Decomposition Formulation**:
|
|
1955
|
+
* **Master Problem**: Solves the relaxed assignment problem (each city has 1 incoming and 1 outgoing edge).
|
|
1956
|
+
* **Subproblem**: Evaluates the candidate tour graph for disconnected components. If isolated subtours exist, it lazily injects a Benders feasibility cut ($\sum_{i,j \in S} x_{ij} \le |S| - 1$) cutting off that cycle.
|
|
1957
|
+
|
|
1958
|
+
```typescript
|
|
1959
|
+
import { Model, expr, minimize, LinExpr, type Var } from "@ohtaman/washi";
|
|
1960
|
+
|
|
1961
|
+
const n = cities.length;
|
|
1962
|
+
const master = new Model("TSP_Master");
|
|
1963
|
+
const x: Record<string, Var> = {};
|
|
1964
|
+
const objExpr = new LinExpr();
|
|
1965
|
+
|
|
1966
|
+
for (let i = 0; i < n; i++) {
|
|
1967
|
+
for (let j = 0; j < n; j++) {
|
|
1968
|
+
if (i !== j) {
|
|
1969
|
+
const v = master.binary(`x_${i}_${j}`);
|
|
1970
|
+
x[`${i}_${j}`] = v;
|
|
1971
|
+
objExpr.addTerm(v, dist(i, j));
|
|
1972
|
+
}
|
|
1973
|
+
}
|
|
1974
|
+
}
|
|
1975
|
+
master.objective(minimize(objExpr));
|
|
1976
|
+
|
|
1977
|
+
// Degree constraints
|
|
1978
|
+
for (let i = 0; i < n; i++) {
|
|
1979
|
+
const outE = new LinExpr();
|
|
1980
|
+
const inE = new LinExpr();
|
|
1981
|
+
for (let j = 0; j < n; j++) {
|
|
1982
|
+
if (i !== j) {
|
|
1983
|
+
outE.addTerm(x[`${i}_${j}`], 1);
|
|
1984
|
+
inE.addTerm(x[`${j}_${i}`], 1);
|
|
1985
|
+
}
|
|
1986
|
+
}
|
|
1987
|
+
master.constraint(expr`${outE} == 1`, `out_${i}`);
|
|
1988
|
+
master.constraint(expr`${inE} == 1`, `in_${i}`);
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
// Solve with Benders lazy subtour elimination
|
|
1992
|
+
const result = await master.solveBenders({
|
|
1993
|
+
subproblem: () => {
|
|
1994
|
+
const cycles = findCycles(n, (i, j) => (x[`${i}_${j}`].value ?? 0) > 0.5);
|
|
1995
|
+
if (cycles.length === 1 && cycles[0].length === n) {
|
|
1996
|
+
return null; // Single tour covers all cities
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1999
|
+
// Subtour detected: inject Benders feasibility cut for smallest cycle
|
|
2000
|
+
const S = cycles.reduce((min, c) => (c.length < min.length ? c : min), cycles[0]);
|
|
2001
|
+
const secExpr = new LinExpr();
|
|
2002
|
+
for (const i of S) {
|
|
2003
|
+
for (const j of S) {
|
|
2004
|
+
if (i !== j) secExpr.addTerm(x[`${i}_${j}`], 1);
|
|
2005
|
+
}
|
|
2006
|
+
}
|
|
2007
|
+
return {
|
|
2008
|
+
kind: "feasibility",
|
|
2009
|
+
constraint: expr`${secExpr} <= ${S.length - 1}`,
|
|
2010
|
+
};
|
|
2011
|
+
},
|
|
2012
|
+
});
|
|
2013
|
+
```
|
|
2014
|
+
|
|
2015
|
+
---
|
|
2016
|
+
|
|
1604
2017
|
# Solvers Overview
|
|
1605
2018
|
|
|
1606
2019
|
WaSHI is designed around a pluggable, decoupled solver architecture. You formulate mathematical models using a unified algebraic API, then solve them with the engine best suited for your problem class.
|
|
@@ -1611,9 +2024,9 @@ WaSHI is designed around a pluggable, decoupled solver architecture. You formula
|
|
|
1611
2024
|
|
|
1612
2025
|
| Solver | Engine License | Problem Classes | Best For | Status in WaSHI |
|
|
1613
2026
|
| :--- | :--- | :--- | :--- | :--- |
|
|
1614
|
-
| **HiGHS** | **MIT** | **LP, MIP, Convex QP** | Industrial-scale optimization, operations research, finance | **Built-in default** (zero configuration) |
|
|
1615
|
-
| **Z3** | **MIT** | **SMT, SAT, Logic, MIP** | Propositional logic, conflict diagnosis (`unsatCore`), rule engines | Optional plugin (`@ohtaman/washi/solvers/z3`) |
|
|
1616
|
-
| **GLPK** | **GPL-3.0** | **LP, MIP** | Academic benchmarks, legacy GLPK compatibility | Optional plugin (`@ohtaman/washi/solvers/glpk`) |
|
|
2027
|
+
| **[HiGHS](https://github.com/ERGO-Code/HiGHS)** | **MIT** | **LP, MIP, Convex QP** | Industrial-scale optimization, operations research, finance | **Built-in default** (zero configuration) |
|
|
2028
|
+
| **[Z3](https://github.com/Z3Prover/z3)** (via [`z3-solver`](https://www.npmjs.com/package/z3-solver)) | **MIT** | **SMT, SAT, Logic, MIP** | Propositional logic, conflict diagnosis (`unsatCore`), rule engines | Optional plugin (`@ohtaman/washi/solvers/z3`) |
|
|
2029
|
+
| **[GLPK](https://www.gnu.org/software/glpk/)** (via [`glpk.js`](https://www.npmjs.com/package/glpk.js)) | **GPL-3.0** | **LP, MIP** | Academic benchmarks, legacy GLPK compatibility | Optional plugin (`@ohtaman/washi/solvers/glpk`) |
|
|
1617
2030
|
| **Mock** | **MIT** | *Any* | Ultra-fast unit testing in CI without loading WebAssembly | Built-in |
|
|
1618
2031
|
|
|
1619
2032
|
---
|
|
@@ -1648,17 +2061,39 @@ const m3 = new Model({ solver: "glpk" });
|
|
|
1648
2061
|
|
|
1649
2062
|
# HiGHS Solver Engine
|
|
1650
2063
|
|
|
1651
|
-
**HiGHS** is high-performance mathematical optimization software developed at the University of Edinburgh. It is widely recognized as the state-of-the-art open-source solver for Linear Programming (LP), Mixed-Integer Programming (MIP), and Convex Quadratic Programming (QP).
|
|
2064
|
+
**HiGHS** is high-performance mathematical optimization software developed at the University of Edinburgh ([ERGO-Code/HiGHS](https://github.com/ERGO-Code/HiGHS)). It is widely recognized as the state-of-the-art open-source solver for Linear Programming (LP), Mixed-Integer Programming (MIP), and Convex Quadratic Programming (QP).
|
|
2065
|
+
|
|
2066
|
+
In `@ohtaman/washi`, HiGHS runs entirely in WebAssembly compiled directly from official upstream source ([ERGO-Code/HiGHS](https://github.com/ERGO-Code/HiGHS)) using its native C API (`highs_c_api.h`), providing zero-config execution across Node.js, Web Workers, and browsers without third-party wrapper dependencies.
|
|
1652
2067
|
|
|
1653
2068
|
---
|
|
1654
2069
|
|
|
1655
2070
|
## Key Features in WaSHI
|
|
1656
2071
|
|
|
1657
|
-
- **Built-in & Zero-Config**: Bundled directly with `@ohtaman/washi
|
|
2072
|
+
- **Built-in & Zero-Config**: Bundled directly with `@ohtaman/washi` with zero external runtime dependencies. No separate peer dependency or C++ compiler required.
|
|
1658
2073
|
- **Permissive MIT License**: 100% commercially friendly.
|
|
1659
2074
|
- **Web Worker Multithreading**: Automatically offloads intensive solving to background Web Workers in browser environments to avoid freezing the UI thread.
|
|
1660
2075
|
- **Warm-Start Support (`initialValue`)**: Supports passing variable primal initial value hints (`initialValue`) to HiGHS `setSolution` for warm starting.
|
|
1661
|
-
- **Fast WASM Execution**:
|
|
2076
|
+
- **Fast WASM Execution**: Runs the directly compiled 32-bit WebAssembly binary at near-native speed.
|
|
2077
|
+
|
|
2078
|
+
---
|
|
2079
|
+
|
|
2080
|
+
## Architecture & WebAssembly Runtime
|
|
2081
|
+
|
|
2082
|
+
WaSHI compiles official HiGHS C++ source directly to WebAssembly (`highs.wasm`) with `-fwasm-exceptions` and single-threaded universal portability (`-DHIGHS_NO_DEFAULT_THREADS=ON`). By interfacing directly with HiGHS's native C API (`highs_c_api.h`), WaSHI achieves zero-copy solution extraction via typed array views on WebAssembly linear memory, rapid upstream release tracking, and a self-contained zero-dependency distribution.
|
|
2083
|
+
|
|
2084
|
+
### Callbacks & Solver Interactivity
|
|
2085
|
+
|
|
2086
|
+
- **Progress Monitoring & Cancellation (Supported)**:
|
|
2087
|
+
Through HiGHS's internal logging and interrupt hooks, WaSHI provides real-time progress callbacks (`onProgress`) and cooperative mid-solve cancellation via standard `AbortSignal`.
|
|
2088
|
+
- **Dynamic Cuts / Lazy Constraints (Not Supported in WASM Wrapper)**:
|
|
2089
|
+
The upstream WebAssembly wrapper intentionally excludes low-level native callback type 8 (`MIP lazy constraints`) due to WASM-to-JS boundary and synchronous memory safety constraints. For problems requiring subtour elimination or Benders decomposition, use an **iterative cutting-plane loop** (solve $\to$ detect violation $\to$ `model.constraint(...)` $\to$ re-solve).
|
|
2090
|
+
|
|
2091
|
+
### WebAssembly Asset Lifecycle & On-Demand Builds
|
|
2092
|
+
|
|
2093
|
+
HiGHS's WebAssembly binary (`public/highs.wasm`) and runtime driver (`runtime.js`) are managed via `highs.config.json` with cryptographic SHA-256 verification:
|
|
2094
|
+
- **Zero-Friction Verification**: Build and distribution pipelines run `node scripts/ensure-highs-wasm.mjs`, verifying local assets in milliseconds before bundling.
|
|
2095
|
+
- **On-Demand Release Download**: When assets are missing or updated, `ensure-highs-wasm.mjs` automatically downloads verified binaries from official GitHub Release assets.
|
|
2096
|
+
- **Compiling from Source**: When updating HiGHS or modifying compiler flags, run `npm run build:highs-wasm` with the Emscripten SDK (`emcc`), CMake, and Ninja installed, and upload the rebuilt assets to the `highs-<version>` GitHub Release tag (`gh release upload highs-v1.15.1 public/highs.wasm src/modeler/solvers/highs/runtime.js --clobber`) so on-demand downloads match `highs.config.json` (or run `.github/workflows/build-wasm.yml` and commit the `highs-wasm-assets` artifact including `highs.config.json`).
|
|
1662
2097
|
|
|
1663
2098
|
---
|
|
1664
2099
|
|
|
@@ -2010,7 +2445,7 @@ export interface SolveModelData extends CheckModelData {
|
|
|
2010
2445
|
|
|
2011
2446
|
Custom solvers declare their supported features using `capabilities`. Before dispatching `solve()` or `check()`, `Model` validates:
|
|
2012
2447
|
- `supportsQP === false` when the objective contains quadratic terms (`QuadExpr`),
|
|
2013
|
-
-
|
|
2448
|
+
- when the model contains logical constraints (`m.logicalConstraints`) and the solver does not support SMT (`supportsSMT !== true`), `Model` automatically linearizes them into equivalent 0-1 MIP binary variables and linear constraints via `linearizeModelForMip`, and
|
|
2014
2449
|
- `supportsWarmStart !== true` when any variable has a primal `initialValue` set.
|
|
2015
2450
|
|
|
2016
2451
|
```typescript
|
|
@@ -2434,6 +2869,8 @@ Explore runnable optimization and logic formulations built with WaSHI. Every exa
|
|
|
2434
2869
|
| **[Portfolio Optimization](docs/examples/portfolio-qp.md)** | Convex QP | HiGHS | `m.continuous()`, `m.constraint()`, `dot()`, `QuadExpr` | Markowitz mean-variance risk minimization ($w^T \Sigma w$) using `QuadExpr` subject to return targets. | [Playground](https://ohtaman.github.io/washi/demo/?preset=portfolio_qp) |
|
|
2435
2870
|
| **[SMT Security Policies](docs/examples/smt-security.md)** | SMT Logic | Z3 | `m.bool()`, `m.constraint()`, `m.check()` | Propositional variables (`BoolVar`) and logical connectives (`implies`, `and`, `not`, `xor`) mixed with integer bounds. | [Playground](https://ohtaman.github.io/washi/demo/?preset=smt_sat_scheduling) |
|
|
2436
2871
|
| **[Conflict Diagnosis (`unsatCore`)](docs/examples/unsat-core.md)** | Unsat Core | Z3 | `m.constraint()`, `m.check()`, `m.unsatCore` | Extracting a conflicting constraint subset (`unsatCore`) when staffing and budget rules contradict each other. | [Playground](https://ohtaman.github.io/washi/demo/?preset=conflict_diagnosis_unsat) |
|
|
2872
|
+
| **[Vehicle Routing (CVRP)](docs/examples/vehicle-routing.md)** | Combinatorial OR / Dantzig-Wolfe | HiGHS | `m.solveColumnGeneration()`, `pricing` | Dynamic route column generation from customer dual shadow prices to minimize total fleet vehicles. | [Decomposition Guide](docs/guide/decomposition.md) |
|
|
2873
|
+
| **[Traveling Salesperson (TSP)](docs/examples/traveling-salesperson.md)** | Combinatorial OR / Benders | HiGHS | `m.solveBenders()`, `subproblem` | Subtour elimination on degree-assignment relaxation using dynamic Benders feasibility cuts. | [Decomposition Guide](docs/guide/decomposition.md) |
|
|
2437
2874
|
|
|
2438
2875
|
## Find by WaSHI API (Reverse Index)
|
|
2439
2876
|
|
|
@@ -2451,6 +2888,8 @@ Looking for a specific modeling method or solver capability? Jump straight to th
|
|
|
2451
2888
|
| `new QuadExpr()` | Convex quadratic objectives ($x^T Q x$) | [Portfolio Optimization](docs/examples/portfolio-qp.md) | [`03-portfolio-qp.ts`](https://github.com/ohtaman/washi/blob/main/examples/node/03-portfolio-qp.ts) |
|
|
2452
2889
|
| `m.bool()` & `.implies()` | Propositional logic & SMT rules | [SMT Security Policies](docs/examples/smt-security.md) | [`04-smt-security-rules.ts`](https://github.com/ohtaman/washi/blob/main/examples/node/04-smt-security-rules.ts) |
|
|
2453
2890
|
| `m.check()` & `m.unsatCore` | Feasibility check & conflict diagnosis | [Conflict Diagnosis](docs/examples/unsat-core.md), [Shift Scheduling](docs/examples/shift-scheduling.md) | [`05-unsat-core-diagnosis.ts`](https://github.com/ohtaman/washi/blob/main/examples/node/05-unsat-core-diagnosis.ts) |
|
|
2891
|
+
| `m.solveColumnGeneration()` | Dantzig-Wolfe Column Generation | [Vehicle Routing](docs/examples/vehicle-routing.md) | [`13-vehicle-routing-column-generation.ts`](https://github.com/ohtaman/washi/blob/main/examples/node/13-vehicle-routing-column-generation.ts) |
|
|
2892
|
+
| `m.solveBenders()` | Benders Decomposition & cut separation | [Traveling Salesperson](docs/examples/traveling-salesperson.md) | [`14-traveling-salesperson-benders.ts`](https://github.com/ohtaman/washi/blob/main/examples/node/14-traveling-salesperson-benders.ts) |
|
|
2454
2893
|
| `solver: "glpk"` | Solving LP/MIP with GLPK plugin | [GLPK Solver Guide](docs/solvers/glpk.md) | [`08-glpk-solver-plugin.ts`](https://github.com/ohtaman/washi/blob/main/examples/node/08-glpk-solver-plugin.ts) |
|
|
2455
2894
|
|
|
2456
2895
|
## Running Examples Locally in Node.js
|
|
@@ -3247,3 +3686,380 @@ async function diagnoseConflicts() {
|
|
|
3247
3686
|
|
|
3248
3687
|
diagnoseConflicts();
|
|
3249
3688
|
```
|
|
3689
|
+
|
|
3690
|
+
---
|
|
3691
|
+
|
|
3692
|
+
# Capacitated Vehicle Routing Problem (CVRP, 50 Customers via Column Generation)
|
|
3693
|
+
|
|
3694
|
+
| Problem Class | Solver | Key APIs | Links |
|
|
3695
|
+
| :--- | :--- | :--- | :--- |
|
|
3696
|
+
| Combinatorial OR / Dantzig-Wolfe | HiGHS (default) | `m.solveColumnGeneration()`, `pricing`, `sum()`, `m.binary()`, `m.constraint()`, `expr` | <ul><li><a href="https://github.com/ohtaman/washi/blob/main/examples/node/13-vehicle-routing-column-generation.ts">Source Code</a></li><li><a href="../guide/decomposition.md">Decomposition Guide</a></li></ul> |
|
|
3697
|
+
|
|
3698
|
+
The **Capacitated Vehicle Routing Problem (CVRP)** determines the minimum number of delivery vehicles needed to visit a set of customers, subject to vehicle cargo capacity, maximum customer stops per shift, and route duration limits.
|
|
3699
|
+
|
|
3700
|
+
In a monolithic MIP formulation, enumerating all possible customer combinations and visit sequences leads to $O(2^n)$ binary variables. With **Column Generation (Dantzig-Wolfe Decomposition)**, routes are generated on demand:
|
|
3701
|
+
1. **Restricted Master Problem (RMP)**: Solves a set partitioning problem that selects a subset of candidate routes so that every customer is visited once.
|
|
3702
|
+
2. **Pricing Subproblem**: Uses the dual values (shadow prices) from the master LP relaxation to construct improving routes with negative reduced cost ($\bar{c} = 1 - \sum \pi_i < 0$) via cheapest insertion and 2-opt local search.
|
|
3703
|
+
|
|
3704
|
+
## Problem Inputs
|
|
3705
|
+
|
|
3706
|
+
We consider a distribution network with a central depot at $(0, 0)$ and **50 customer locations**:
|
|
3707
|
+
|
|
3708
|
+
- **Vehicle Cargo Capacity**: At most **10 units** per vehicle.
|
|
3709
|
+
- **Maximum Stops**: At most **4 customers** per route.
|
|
3710
|
+
- **Maximum Shift Duration**: At most **200 minutes** (transit time + service time) per route.
|
|
3711
|
+
- **Transit & Service Times**: Euclidean distances between nodes (1 unit = 1 minute) plus **10 minutes** of unloading service time per customer.
|
|
3712
|
+
|
|
3713
|
+
## Implementation in WaSHI
|
|
3714
|
+
|
|
3715
|
+
```typescript
|
|
3716
|
+
import { Model, expr, minimize, sum } from "@ohtaman/washi";
|
|
3717
|
+
|
|
3718
|
+
// --- Extracted Routing Helpers ---
|
|
3719
|
+
|
|
3720
|
+
// 2-opt local search to eliminate crossing paths in a tour
|
|
3721
|
+
function optimize2Opt(tour, dist) {
|
|
3722
|
+
let best = [...tour];
|
|
3723
|
+
let improved = true;
|
|
3724
|
+
|
|
3725
|
+
while (improved) {
|
|
3726
|
+
improved = false;
|
|
3727
|
+
for (let i = 1; i < best.length - 2; i++) {
|
|
3728
|
+
for (let j = i + 1; j < best.length - 1; j++) {
|
|
3729
|
+
const dCurrent = dist(best[i - 1], best[i]) + dist(best[j], best[j + 1]);
|
|
3730
|
+
const dSwap = dist(best[i - 1], best[j]) + dist(best[i], best[j + 1]);
|
|
3731
|
+
if (dSwap < dCurrent) {
|
|
3732
|
+
best = [...best.slice(0, i), ...best.slice(i, j + 1).reverse(), ...best.slice(j + 1)];
|
|
3733
|
+
improved = true;
|
|
3734
|
+
}
|
|
3735
|
+
}
|
|
3736
|
+
}
|
|
3737
|
+
}
|
|
3738
|
+
return best;
|
|
3739
|
+
}
|
|
3740
|
+
|
|
3741
|
+
// Inserts a customer into the tour at the position that minimizes detour distance
|
|
3742
|
+
function insertCheapest(tour, id, dist) {
|
|
3743
|
+
let bestPos = 1;
|
|
3744
|
+
let minCost = Infinity;
|
|
3745
|
+
|
|
3746
|
+
for (let p = 1; p < tour.length; p++) {
|
|
3747
|
+
const cost = dist(tour[p - 1], id) + dist(id, tour[p]) - dist(tour[p - 1], tour[p]);
|
|
3748
|
+
if (cost < minCost) {
|
|
3749
|
+
minCost = cost;
|
|
3750
|
+
bestPos = p;
|
|
3751
|
+
}
|
|
3752
|
+
}
|
|
3753
|
+
return [...tour.slice(0, bestPos), id, ...tour.slice(bestPos)];
|
|
3754
|
+
}
|
|
3755
|
+
|
|
3756
|
+
// Calculates transit time and total duration (transit + service) for a tour
|
|
3757
|
+
function calcRouteTime(tour, dist, serviceTimes) {
|
|
3758
|
+
let travelTime = 0;
|
|
3759
|
+
for (let i = 0; i < tour.length - 1; i++) {
|
|
3760
|
+
travelTime += dist(tour[i], tour[i + 1]);
|
|
3761
|
+
}
|
|
3762
|
+
const serviceTime = tour.slice(1, -1).reduce((acc, id) => acc + (serviceTimes[id] ?? 0), 0);
|
|
3763
|
+
return { travelTime, totalTime: travelTime + serviceTime };
|
|
3764
|
+
}
|
|
3765
|
+
|
|
3766
|
+
// Pricing subproblem: Finds an improving route with negative reduced cost (1 - dualSum < 0)
|
|
3767
|
+
function findImprovingRoute(candidates, config) {
|
|
3768
|
+
let bestRoute = null;
|
|
3769
|
+
let minReducedCost = -1e-4;
|
|
3770
|
+
|
|
3771
|
+
for (const seed of candidates.slice(0, 15)) {
|
|
3772
|
+
let tour = ["Depot", seed.id, "Depot"];
|
|
3773
|
+
let load = seed.demand;
|
|
3774
|
+
let dualSum = seed.dual;
|
|
3775
|
+
|
|
3776
|
+
for (const next of candidates) {
|
|
3777
|
+
if (tour.includes(next.id)) continue;
|
|
3778
|
+
if (load + next.demand > config.vehicleCapacity) continue;
|
|
3779
|
+
if (tour.length - 2 >= config.maxStops) continue;
|
|
3780
|
+
|
|
3781
|
+
const candidateTour = optimize2Opt(insertCheapest(tour, next.id, config.dist), config.dist);
|
|
3782
|
+
const { totalTime } = calcRouteTime(candidateTour, config.dist, config.serviceTimes);
|
|
3783
|
+
|
|
3784
|
+
if (totalTime <= config.maxRouteTime) {
|
|
3785
|
+
tour = candidateTour;
|
|
3786
|
+
load += next.demand;
|
|
3787
|
+
dualSum += next.dual;
|
|
3788
|
+
}
|
|
3789
|
+
}
|
|
3790
|
+
|
|
3791
|
+
const reducedCost = 1 - dualSum;
|
|
3792
|
+
if (reducedCost < minReducedCost) {
|
|
3793
|
+
minReducedCost = reducedCost;
|
|
3794
|
+
bestRoute = tour;
|
|
3795
|
+
}
|
|
3796
|
+
}
|
|
3797
|
+
return bestRoute;
|
|
3798
|
+
}
|
|
3799
|
+
|
|
3800
|
+
// --- Main CVRP via Column Generation ---
|
|
3801
|
+
|
|
3802
|
+
async function runCVRP() {
|
|
3803
|
+
const vehicleCapacity = 10;
|
|
3804
|
+
const maxStops = 4;
|
|
3805
|
+
const maxRouteTime = 200; // minutes
|
|
3806
|
+
|
|
3807
|
+
// Central depot and 50 customer locations
|
|
3808
|
+
const depot = { id: "Depot", x: 0, y: 0 };
|
|
3809
|
+
const customers = Array.from({ length: 50 }, (_, i) => {
|
|
3810
|
+
const angle = i * 2.4;
|
|
3811
|
+
const radius = 15 + (i % 5) * 8;
|
|
3812
|
+
return {
|
|
3813
|
+
id: `C${i + 1}`,
|
|
3814
|
+
x: Math.round(Math.cos(angle) * radius),
|
|
3815
|
+
y: Math.round(Math.sin(angle) * radius),
|
|
3816
|
+
demand: 1 + (i % 4),
|
|
3817
|
+
serviceTime: 10,
|
|
3818
|
+
};
|
|
3819
|
+
});
|
|
3820
|
+
|
|
3821
|
+
const nodes = Object.fromEntries([depot, ...customers].map((n) => [n.id, n]));
|
|
3822
|
+
const dist = (a, b) =>
|
|
3823
|
+
Math.round(Math.hypot(nodes[a].x - nodes[b].x, nodes[a].y - nodes[b].y));
|
|
3824
|
+
const serviceTimes = Object.fromEntries(customers.map((c) => [c.id, c.serviceTime]));
|
|
3825
|
+
|
|
3826
|
+
const routeConfig = { dist, serviceTimes, vehicleCapacity, maxStops, maxRouteTime };
|
|
3827
|
+
|
|
3828
|
+
// 1. Build Restricted Master Problem (RMP)
|
|
3829
|
+
const master = new Model("CVRP_FleetMaster");
|
|
3830
|
+
|
|
3831
|
+
// Initial naive routes: 1 dedicated vehicle per customer
|
|
3832
|
+
const initialRoutes = customers.map((c) => master.binary(`route_init_${c.id}`));
|
|
3833
|
+
master.objective(minimize(sum(initialRoutes)));
|
|
3834
|
+
|
|
3835
|
+
// Each customer must be visited by exactly 1 vehicle
|
|
3836
|
+
for (let i = 0; i < customers.length; i++) {
|
|
3837
|
+
master.constraint(expr`${initialRoutes[i]} == 1`, `visit_${customers[i].id}`);
|
|
3838
|
+
}
|
|
3839
|
+
const visitConstrs = master.constraints;
|
|
3840
|
+
|
|
3841
|
+
console.log(`Initial state: 50 naive single-customer routes (50 vehicles).`);
|
|
3842
|
+
console.log("Solving via Column Generation with dual-directed heuristic pricing...");
|
|
3843
|
+
|
|
3844
|
+
// 2. Solve via Column Generation
|
|
3845
|
+
const result = await master.solveColumnGeneration({
|
|
3846
|
+
finalizeAsMIP: true,
|
|
3847
|
+
pricing: async () => {
|
|
3848
|
+
// 1. Sort customers by shadow price (dual profit) descending
|
|
3849
|
+
const candidates = customers
|
|
3850
|
+
.map((c, i) => ({ id: c.id, demand: c.demand, dual: visitConstrs[i].dual ?? 0 }))
|
|
3851
|
+
.filter((c) => c.dual > 0)
|
|
3852
|
+
.sort((a, b) => b.dual - a.dual);
|
|
3853
|
+
|
|
3854
|
+
// 2. Solve pricing subproblem: find route with negative reduced cost (dualSum > 1)
|
|
3855
|
+
const bestRoute = findImprovingRoute(candidates, routeConfig);
|
|
3856
|
+
if (!bestRoute) return null; // No improving column found
|
|
3857
|
+
|
|
3858
|
+
// 3. Propose new column to Restricted Master Problem
|
|
3859
|
+
const { travelTime, totalTime } = calcRouteTime(bestRoute, dist, serviceTimes);
|
|
3860
|
+
const coefficients = {};
|
|
3861
|
+
for (const id of bestRoute.slice(1, -1)) {
|
|
3862
|
+
coefficients[`visit_${id}`] = 1;
|
|
3863
|
+
}
|
|
3864
|
+
|
|
3865
|
+
return {
|
|
3866
|
+
cost: 1,
|
|
3867
|
+
type: "Binary",
|
|
3868
|
+
coefficients,
|
|
3869
|
+
meta: { tour: bestRoute, travelTime, totalTime },
|
|
3870
|
+
};
|
|
3871
|
+
},
|
|
3872
|
+
});
|
|
3873
|
+
|
|
3874
|
+
console.log(`\nStatus: ${result.status}`);
|
|
3875
|
+
console.log(`Fleet Size: ${Math.round(result.objectiveValue ?? 0)} vehicles (heuristic column generation)`);
|
|
3876
|
+
console.log(`Total Columns Generated: ${result.columnsAdded}`);
|
|
3877
|
+
|
|
3878
|
+
console.log("\nSample routes selected in fleet:");
|
|
3879
|
+
const activeRoutes = result.generatedColumns.filter((c) => (c.variable.value ?? 0) > 0.5);
|
|
3880
|
+
for (const r of activeRoutes.slice(0, 5)) {
|
|
3881
|
+
const meta = r.proposal.meta;
|
|
3882
|
+
console.log(` ${meta?.tour.join(" -> ")} (Transit: ${meta?.travelTime}m, Total: ${meta?.totalTime}m)`);
|
|
3883
|
+
}
|
|
3884
|
+
}
|
|
3885
|
+
|
|
3886
|
+
runCVRP();
|
|
3887
|
+
```
|
|
3888
|
+
|
|
3889
|
+
## Notes
|
|
3890
|
+
|
|
3891
|
+
- **Master Problem and Pricing Separation**: The master problem solves the LP relaxation and final set-partitioning MIP, while the `pricing` callback searches for negative-reduced-cost routes using cheapest insertion and 2-opt local search.
|
|
3892
|
+
- **Dual Value Access**: Dual values from the master LP relaxation are read from `visitConstrs[i].dual` to prioritize customers with higher shadow prices during route construction.
|
|
3893
|
+
- **Route Metadata**: Each proposed column attaches `{ tour, travelTime, totalTime }` under `meta`, allowing the ordered stop sequence to be inspected from `result.generatedColumns` after the solve completes.
|
|
3894
|
+
|
|
3895
|
+
---
|
|
3896
|
+
|
|
3897
|
+
# Traveling Salesperson Problem (TSP via Benders Decomposition)
|
|
3898
|
+
|
|
3899
|
+
| Problem Class | Solver | Key APIs | Links |
|
|
3900
|
+
| :--- | :--- | :--- | :--- |
|
|
3901
|
+
| Combinatorial OR / Benders | HiGHS (default) | `m.solveBenders()`, `subproblem`, `m.binary()`, `m.constraint()`, `expr` | <ul><li><a href="https://github.com/ohtaman/washi/blob/main/examples/node/14-traveling-salesperson-benders.ts">Source Code</a></li><li><a href="../guide/decomposition.md">Decomposition Guide</a></li></ul> |
|
|
3902
|
+
|
|
3903
|
+
The **Traveling Salesperson Problem (TSP)** seeks the shortest closed tour that visits every city in a given set once and returns to the starting city.
|
|
3904
|
+
|
|
3905
|
+
In the **Dantzig-Fulkerson-Johnson (DFJ)** formulation, preventing disconnected cycles (**subtours**) requires subtour elimination constraints for every non-empty proper subset of cities $S \subset V$:
|
|
3906
|
+
|
|
3907
|
+
$$\sum_{i \in S, j \notin S} x_{ij} \ge 1 \quad \forall S \subset V, \; 2 \le |S| \le |V| - 1$$
|
|
3908
|
+
|
|
3909
|
+
Because there are $2^n - 2$ subsets, enumerating all subtour constraints upfront is impractical as $n$ grows. With **Benders Decomposition (Lazy Constraint Separation)**, constraints are added only when violated:
|
|
3910
|
+
1. **Master Problem**: Solves the degree-assignment relaxation (each city has 1 incoming and 1 outgoing edge).
|
|
3911
|
+
2. **Separation Subproblem**: Inspects the candidate solution graph. If disconnected cycles exist, it adds a **Benders Feasibility Cut** eliminating the smallest cycle and resolves until a single connected tour is obtained.
|
|
3912
|
+
|
|
3913
|
+
## Problem Inputs
|
|
3914
|
+
|
|
3915
|
+
We consider **5 cities** on a 2D Euclidean coordinate grid:
|
|
3916
|
+
|
|
3917
|
+
| City | Coordinates $(x, y)$ |
|
|
3918
|
+
| :--- | :---: |
|
|
3919
|
+
| **City A (0)** | $(0, 0)$ |
|
|
3920
|
+
| **City B (1)** | $(1, 0)$ |
|
|
3921
|
+
| **City C (2)** | $(1, 2)$ |
|
|
3922
|
+
| **City D (3)** | $(0, 2)$ |
|
|
3923
|
+
| **City E (4)** | $(0.5, 3)$ |
|
|
3924
|
+
|
|
3925
|
+
## Implementation in WaSHI
|
|
3926
|
+
|
|
3927
|
+
```typescript
|
|
3928
|
+
import { Model, expr, minimize, LinExpr } from "@ohtaman/washi";
|
|
3929
|
+
|
|
3930
|
+
async function runTSP() {
|
|
3931
|
+
const cities = [
|
|
3932
|
+
{ name: "A", x: 0, y: 0 },
|
|
3933
|
+
{ name: "B", x: 1, y: 0 },
|
|
3934
|
+
{ name: "C", x: 1, y: 2 },
|
|
3935
|
+
{ name: "D", x: 0, y: 2 },
|
|
3936
|
+
{ name: "E", x: 0.5, y: 3 },
|
|
3937
|
+
];
|
|
3938
|
+
const n = cities.length;
|
|
3939
|
+
|
|
3940
|
+
function dist(i, j) {
|
|
3941
|
+
const dx = cities[i].x - cities[j].x;
|
|
3942
|
+
const dy = cities[i].y - cities[j].y;
|
|
3943
|
+
return Math.hypot(dx, dy);
|
|
3944
|
+
}
|
|
3945
|
+
|
|
3946
|
+
// 1. Master Problem: Degree assignment relaxation
|
|
3947
|
+
const master = new Model("TSP_BendersMaster");
|
|
3948
|
+
const x = {};
|
|
3949
|
+
|
|
3950
|
+
for (let i = 0; i < n; i++) {
|
|
3951
|
+
x[i] = {};
|
|
3952
|
+
for (let j = 0; j < n; j++) {
|
|
3953
|
+
if (i !== j) {
|
|
3954
|
+
x[i][j] = master.binary(`x_${i}_${j}`);
|
|
3955
|
+
}
|
|
3956
|
+
}
|
|
3957
|
+
}
|
|
3958
|
+
|
|
3959
|
+
// Objective: Minimize total tour distance
|
|
3960
|
+
let totalDistance = new LinExpr();
|
|
3961
|
+
for (let i = 0; i < n; i++) {
|
|
3962
|
+
for (let j = 0; j < n; j++) {
|
|
3963
|
+
if (i !== j) {
|
|
3964
|
+
totalDistance = totalDistance.add(x[i][j].mul(dist(i, j)));
|
|
3965
|
+
}
|
|
3966
|
+
}
|
|
3967
|
+
}
|
|
3968
|
+
master.objective(minimize(totalDistance));
|
|
3969
|
+
|
|
3970
|
+
// Each city must have 1 outgoing and 1 incoming edge
|
|
3971
|
+
for (let i = 0; i < n; i++) {
|
|
3972
|
+
let outExpr = new LinExpr();
|
|
3973
|
+
let inExpr = new LinExpr();
|
|
3974
|
+
for (let j = 0; j < n; j++) {
|
|
3975
|
+
if (i !== j) {
|
|
3976
|
+
outExpr = outExpr.add(x[i][j]);
|
|
3977
|
+
inExpr = inExpr.add(x[j][i]);
|
|
3978
|
+
}
|
|
3979
|
+
}
|
|
3980
|
+
master.constraint(expr`${outExpr} == 1`, `out_${i}`);
|
|
3981
|
+
master.constraint(expr`${inExpr} == 1`, `in_${i}`);
|
|
3982
|
+
}
|
|
3983
|
+
|
|
3984
|
+
console.log("Solving TSP with Benders lazy subtour elimination...");
|
|
3985
|
+
|
|
3986
|
+
// 2. Solve via Benders Decomposition
|
|
3987
|
+
let cutCounter = 0;
|
|
3988
|
+
const result = await master.solveBenders({
|
|
3989
|
+
subproblem: () => {
|
|
3990
|
+
// Find active directed edges in the master solution
|
|
3991
|
+
const succ = {};
|
|
3992
|
+
for (let i = 0; i < n; i++) {
|
|
3993
|
+
for (let j = 0; j < n; j++) {
|
|
3994
|
+
if (i !== j && Math.round(x[i][j].value ?? 0) === 1) {
|
|
3995
|
+
succ[i] = j;
|
|
3996
|
+
}
|
|
3997
|
+
}
|
|
3998
|
+
}
|
|
3999
|
+
|
|
4000
|
+
// Trace connected cycles
|
|
4001
|
+
const visited = new Set();
|
|
4002
|
+
const cycles = [];
|
|
4003
|
+
for (let i = 0; i < n; i++) {
|
|
4004
|
+
if (!visited.has(i)) {
|
|
4005
|
+
const cycle = [];
|
|
4006
|
+
let curr = i;
|
|
4007
|
+
while (!visited.has(curr)) {
|
|
4008
|
+
visited.add(curr);
|
|
4009
|
+
cycle.push(curr);
|
|
4010
|
+
curr = succ[curr];
|
|
4011
|
+
}
|
|
4012
|
+
cycles.push(cycle);
|
|
4013
|
+
}
|
|
4014
|
+
}
|
|
4015
|
+
|
|
4016
|
+
// If more than 1 cycle, subtours exist: inject Benders feasibility cut
|
|
4017
|
+
if (cycles.length > 1) {
|
|
4018
|
+
const smallestCycle = cycles.reduce((min, c) => (c.length < min.length ? c : min));
|
|
4019
|
+
const S = new Set(smallestCycle);
|
|
4020
|
+
cutCounter++;
|
|
4021
|
+
console.log(`Subtour detected: [${smallestCycle.map(k => cities[k].name).join(" -> ")}]. Adding Benders cut #${cutCounter}.`);
|
|
4022
|
+
|
|
4023
|
+
let cutLhs = new LinExpr();
|
|
4024
|
+
for (const i of S) {
|
|
4025
|
+
for (let j = 0; j < n; j++) {
|
|
4026
|
+
if (!S.has(j)) {
|
|
4027
|
+
cutLhs = cutLhs.add(x[i][j]);
|
|
4028
|
+
}
|
|
4029
|
+
}
|
|
4030
|
+
}
|
|
4031
|
+
|
|
4032
|
+
return {
|
|
4033
|
+
kind: "feasibility",
|
|
4034
|
+
constraint: expr`${cutLhs} >= 1`,
|
|
4035
|
+
name: `subtour_cut_${cutCounter}`,
|
|
4036
|
+
};
|
|
4037
|
+
}
|
|
4038
|
+
|
|
4039
|
+
return null; // Single connected tour found
|
|
4040
|
+
},
|
|
4041
|
+
});
|
|
4042
|
+
|
|
4043
|
+
console.log(`\nStatus: ${result.status}`);
|
|
4044
|
+
console.log(`Optimal Tour Distance: ${result.objectiveValue?.toFixed(3)}`);
|
|
4045
|
+
console.log(`Total Benders Cuts Added: ${result.cutsAdded}`);
|
|
4046
|
+
console.log(`Iterations: ${result.iterations}`);
|
|
4047
|
+
|
|
4048
|
+
console.log("\nOptimal tour edges:");
|
|
4049
|
+
for (let i = 0; i < n; i++) {
|
|
4050
|
+
for (let j = 0; j < n; j++) {
|
|
4051
|
+
if (i !== j && (result.values[`x_${i}_${j}`] ?? 0) > 0.5) {
|
|
4052
|
+
console.log(` ${cities[i].name} -> ${cities[j].name} (distance: ${dist(i, j).toFixed(3)})`);
|
|
4053
|
+
}
|
|
4054
|
+
}
|
|
4055
|
+
}
|
|
4056
|
+
}
|
|
4057
|
+
|
|
4058
|
+
runTSP();
|
|
4059
|
+
```
|
|
4060
|
+
|
|
4061
|
+
## Notes
|
|
4062
|
+
|
|
4063
|
+
- **Lazy Subtour Elimination**: The master problem starts with only the $2n$ degree constraints, and subtour elimination cuts are added on demand when disconnected cycles appear in the master solution.
|
|
4064
|
+
- **Separation Callback**: The `subproblem` callback reads candidate edge values via `x[i][j].value`, traces connected components in $O(n)$, and returns `{ kind: "feasibility", constraint: ... }` when multiple cycles are present.
|
|
4065
|
+
- **Termination Condition**: Returning `null` from `subproblem` indicates that no violated subtour constraints remain.
|