@ohtaman/washi 0.6.0 → 0.7.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 +103 -421
- package/README.md +103 -421
- package/README.zh-CN.md +103 -421
- package/THIRD_PARTY_LICENSES.md +5 -3
- package/dist/browser/solvers/glpk.js +2 -2
- package/dist/browser/solvers/glpk.min.js +1 -1
- package/dist/browser/washi.js +777 -261
- package/dist/browser/washi.min.js +3 -3
- package/dist/lib/{registry-C20sxTwm.js → registry-D7TZ3Wh_.js} +88 -18
- package/dist/lib/solvers/glpk.js +3 -3
- package/dist/lib/solvers/z3.js +1 -1
- package/dist/lib/washi.js +864 -421
- package/dist/types/index.d.ts +1 -1
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/modeler/expression.d.ts +47 -26
- package/dist/types/modeler/expression.d.ts.map +1 -1
- package/dist/types/modeler/index.d.ts +2 -2
- package/dist/types/modeler/index.d.ts.map +1 -1
- package/dist/types/modeler/model.d.ts +12 -6
- package/dist/types/modeler/model.d.ts.map +1 -1
- package/dist/types/modeler/parser.d.ts +25 -5
- package/dist/types/modeler/parser.d.ts.map +1 -1
- package/dist/types/modeler/solvers/glpk/solver.d.ts.map +1 -1
- package/dist/types/modeler/solvers/highs/solver.d.ts.map +1 -1
- package/dist/types/modeler/solvers/highs/worker-client.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/docs/examples/diet-sensitivity.md +15 -14
- package/docs/examples/ecommerce-coupons.md +15 -22
- package/docs/examples/index.md +14 -12
- package/docs/examples/knapsack.md +8 -7
- package/docs/examples/portfolio-qp.md +11 -4
- package/docs/examples/production.md +4 -4
- package/docs/examples/shift-scheduling.md +20 -7
- package/docs/examples/smt-security.md +16 -4
- package/docs/examples/transportation.md +46 -19
- package/docs/examples/traveling-salesperson.md +28 -44
- package/docs/examples/unsat-core.md +15 -5
- package/docs/examples/vehicle-routing.md +10 -8
- package/docs/getting-started/ai-agents.md +25 -14
- package/docs/getting-started/cross-origin.md +56 -28
- package/docs/getting-started/index.md +40 -69
- package/docs/getting-started/runtimes.md +66 -61
- package/docs/guide/constraints.md +46 -40
- package/docs/guide/decomposition.md +127 -259
- package/docs/guide/expressions.md +90 -39
- package/docs/guide/formulation-tips.md +212 -0
- package/docs/guide/objectives.md +7 -4
- package/docs/guide/unsat-core.md +18 -6
- package/docs/guide/variables.md +33 -38
- package/docs/ja/getting-started/ai-agents.md +24 -14
- package/docs/solvers/highs.md +27 -6
- package/docs/solvers/index.md +1 -1
- package/docs/solvers/z3.md +3 -0
- package/docs/zh/getting-started/ai-agents.md +24 -14
- package/llms-full.txt +929 -687
- package/llms.txt +21 -19
- package/package.json +3 -3
- package/skills/washi/SKILL.md +14 -10
package/llms-full.txt
CHANGED
|
@@ -57,7 +57,7 @@ import "@ohtaman/washi/solvers/glpk";
|
|
|
57
57
|
1. **Bundled WASM by default (no implicit CDN fallback)**:
|
|
58
58
|
- In Node.js and modern bundlers, WebAssembly assets resolve relative to the module automatically.
|
|
59
59
|
- In browser deployments, deploy the full `dist/browser/` directory (JS, Worker, and `.wasm` files) together. If a local WASM URL is missing or returns an HTML SPA fallback, initialization fails immediately and reports the attempted URL—it **never** silently switches to a public CDN.
|
|
60
|
-
- In browser runtimes, to load `highs.wasm` from a custom location, call `setHighsWasmUrl("https://.../highs.wasm")` or pass `await m.solve({ wasmUrl: "https://.../highs.wasm" })` (ignored in Node.js, which always loads
|
|
60
|
+
- In browser runtimes, to load `highs.wasm` from a custom location, call `setHighsWasmUrl("https://.../highs.wasm")` or pass `await m.solve({ wasmUrl: "https://.../highs.wasm" })` (ignored in Node.js, which always loads WaSHI's bundled local `highs.wasm` runtime).
|
|
61
61
|
2. **Z3 requires a Secure Context, Cross-Origin Isolation, and CORS in browsers**:
|
|
62
62
|
- Z3 WebAssembly uses `SharedArrayBuffer` multithreading. Browser pages running Z3 must run in a secure context (`window.isSecureContext === true`, i.e., HTTPS or `localhost`) and be cross-origin isolated (`window.crossOriginIsolated === true`) by serving:
|
|
63
63
|
- `Cross-Origin-Opener-Policy: same-origin` (on the document)
|
|
@@ -66,7 +66,7 @@ import "@ohtaman/washi/solvers/glpk";
|
|
|
66
66
|
- Importing `@ohtaman/washi` never auto-injects a Service Worker. When a secure context or cross-origin isolation is absent in a browser, Z3 rejects immediately with a descriptive error.
|
|
67
67
|
3. **Web Workers & Cancellation (`AbortSignal`)**:
|
|
68
68
|
- Pre-solve cancellation (`signal.aborted` before execution starts) is supported across all solvers.
|
|
69
|
-
- In browsers where `Worker` is available, HiGHS runs in a dedicated Web Worker by default (`useWorker: true`). Passing `{ signal }` to `m.solve({ signal })` preemptively terminates an in-flight Worker solve (or cancels queued Worker requests).
|
|
69
|
+
- In browsers where `Worker` is available on a non-opaque effective origin (`window.origin !== "null"`), HiGHS runs in a dedicated Web Worker by default (`useWorker: true`). On opaque effective origins (`window.origin === "null"`, such as `file:` URLs or `sandbox="allow-scripts"` `<iframe>` embeds without `allow-same-origin`), HiGHS defaults to main-thread execution (`useWorker: false`). Passing `{ signal }` to `m.solve({ signal })` preemptively terminates an in-flight Worker solve (or cancels queued Worker requests).
|
|
70
70
|
- On main-thread HiGHS (`useWorker: false` or in Node.js), cooperative mid-solve cancellation is supported when `abort()` is triggered synchronously inside an `onProgress` callback at a solver interrupt checkpoint; arbitrary asynchronous aborts from outside cannot preempt a blocked synchronous WASM thread until a callback checkpoint or completion. Single-threaded WASM solvers without interrupt hooks (GLPK, Z3) check `signal` before execution starts.
|
|
71
71
|
|
|
72
72
|
---
|
|
@@ -82,6 +82,7 @@ const m = new Model("ExampleModel");
|
|
|
82
82
|
|
|
83
83
|
// Scalar variables
|
|
84
84
|
const x = m.continuous("x", { lb: 0, ub: 100 });
|
|
85
|
+
const y = m.continuous("y", { lb: 0, ub: 100 });
|
|
85
86
|
const n = m.integer("n", { lb: 0, ub: 10 });
|
|
86
87
|
const b = m.binary("b");
|
|
87
88
|
const flag = m.bool("flag"); // Propositional boolean (for Z3 / logical constraints)
|
|
@@ -103,12 +104,12 @@ const mixed = m.binary(["A", "B"] as const, 3); // Record<"A" | "B", Var[]>
|
|
|
103
104
|
### Step 2: Build Expressions (`expr`, `sum`, `dot`)
|
|
104
105
|
|
|
105
106
|
- **Unified `expr` template tag**:
|
|
106
|
-
- Linear expression (`LinExpr`): `` expr`30 * ${x} + 50 * ${n} - 10` ``
|
|
107
|
-
- Quadratic expression (`QuadExpr`): `` expr`${x}^2 + 2 * ${x} * ${
|
|
108
|
-
- Linear constraint (`Constraint`): `` expr`${x} + 2 * ${n} <= 40` `` (supports `<=`, `>=`, `==`, and `=`).
|
|
109
|
-
- **Aggregations**:
|
|
110
|
-
- `sum(
|
|
111
|
-
- `dot(a, b)` computes the inner product of matching 1D arrays, 2D arrays (`number[][]` / `Var[][]
|
|
107
|
+
- Linear expression (`LinExpr`): `` expr`30 * ${x} + 50 * ${n} - 10` ``, `` expr`dot(${profit}, ${byItem}) - 20 * ${b}` ``, `` expr`sum((${price} - ${cost}) * ${byItem})` ``
|
|
108
|
+
- Quadratic expression (`QuadExpr`): `` expr`${x}^2 + 2 * ${x} * ${y} + ${y}^2` ``, `` expr`sum((${vec} - ${target})^2)` `` (supported for convex QP objectives on purely continuous models using `m.continuous`; built-in HiGHS does not support MIQP models containing integer, binary, or boolean variables).
|
|
109
|
+
- Linear constraint (`Constraint`): `` expr`${x} + 2 * ${n} <= 40` ``, `` expr`sum(${byItem}) <= 100` `` (supports `<=`, `>=`, `==`, and `=`).
|
|
110
|
+
- **Aggregations & Element-Wise Container Arithmetic**:
|
|
111
|
+
- `sum(containerOrItems, mapper?)` (usable inside `` expr`sum(...)` `` or standalone `sum(...)`) sums a 1D/2D `Array`, `Iterable`, or plain `Record` of `Var` / `LinExpr` / `number` (without needing `Object.values(record)`), or maps over items/counts via `sum(items, (item, i) => ...)`. Inside `` expr`sum(...)` ``, element-wise container arithmetic (`+`, `-`, `*`, `/`, `^2`) is also supported.
|
|
112
|
+
- `dot(a, b)` (usable inside `` expr`dot(${a}, ${b})` `` or standalone `dot(a, b)`) computes the inner product of matching 1D arrays, 2D arrays (`number[][]` / `Var[][]`, computing the Frobenius inner product $\sum_{i,j} A_{i,j} B_{i,j}$), plain records (`Record<string, ...>`), or mixed Array/Record containers in either operand order.
|
|
112
113
|
- **Fluent methods**:
|
|
113
114
|
- Arithmetic (on `LinExpr` and `QuadExpr`; `Var` supports `.add()`, `.sub()`, `.mul()`, `.neg()`): `.add(other)`, `.sub(other)`, `.mul(other)`, `.div(scalar)`, `.neg()`
|
|
114
115
|
- Comparisons (on `Var` and `LinExpr`): `.le(rhs, name?)`, `.ge(rhs, name?)`, `.eq(rhs, name?)`
|
|
@@ -122,8 +123,8 @@ Constructing an `expr` or `.le()` object does **not** attach it to the model unt
|
|
|
122
123
|
// Set objective using maximize() or minimize()
|
|
123
124
|
m.objective(maximize(expr`30 * ${x} + 50 * ${n}`));
|
|
124
125
|
|
|
125
|
-
// Register constraints on the model
|
|
126
|
-
m.constraint(expr`${x} + 2 * ${n} <= 40`, "resource_limit");
|
|
126
|
+
// Register constraints on the model (returns the registered Constraint / BoolExpr or array)
|
|
127
|
+
const limit = m.constraint(expr`${x} + 2 * ${n} <= 40`, "resource_limit");
|
|
127
128
|
|
|
128
129
|
// Indicator constraint (MIP Big-M reformulation: b === 1 => x <= 50)
|
|
129
130
|
m.indicator(b, expr`${x} <= 50`, { activeValue: 1, name: "cap_when_b" });
|
|
@@ -161,10 +162,13 @@ export async function runMipExample() {
|
|
|
161
162
|
const qty = m.integer(products, { lb: 0, ub: 100 });
|
|
162
163
|
const setup = m.binary("setup");
|
|
163
164
|
|
|
164
|
-
m.objective(maximize(expr
|
|
165
|
-
m.constraint(expr
|
|
166
|
-
m.constraint(expr
|
|
167
|
-
m.
|
|
165
|
+
m.objective(maximize(expr`dot(${profit}, ${qty}) - 20 * ${setup}`));
|
|
166
|
+
m.constraint(expr`dot(${woodPerUnit}, ${qty}) <= 40`, "wood_cap");
|
|
167
|
+
m.constraint(expr`dot(${laborPerUnit}, ${qty}) <= 50`, "labor_cap");
|
|
168
|
+
m.indicator(setup, expr`sum(${qty}) <= 0`, {
|
|
169
|
+
activeValue: 0,
|
|
170
|
+
name: "require_setup",
|
|
171
|
+
});
|
|
168
172
|
|
|
169
173
|
const status = await m.solve();
|
|
170
174
|
const report = m.validateSolution();
|
|
@@ -269,24 +273,46 @@ export function verifyNegativePatterns() {
|
|
|
269
273
|
}
|
|
270
274
|
```
|
|
271
275
|
|
|
276
|
+
### Practical OR Modeling Guardrails
|
|
277
|
+
|
|
278
|
+
- **Prefer inequalities (`>=` / `<=`) over exact equalities (`==`)** for demand coverage and resource limits when objective costs are positive; reserve `==` for physical conservation laws (e.g., flow balance, portfolio weights summing to `1`).
|
|
279
|
+
- **Always provide finite `lb` and `ub` bounds** on variables referenced inside `m.indicator(...)` and omit `M` so WaSHI automatically derives the tightest valid Big-M instead of falling back to `1e6`.
|
|
280
|
+
- **Use soft constraints (elastic slack variables)** for non-rigid business targets (e.g., preferred staffing, safety stock, day-off requests) by adding a non-negative slack variable (`lb: 0`) with a tiered objective penalty, reserving hard constraints for physical or legal invariants.
|
|
281
|
+
|
|
272
282
|
---
|
|
273
283
|
|
|
274
284
|
# Introduction & Installation
|
|
275
285
|
|
|
276
|
-
|
|
286
|
+
**WaSHI** (WebAssembly-backed, Solver-independent, High-level modeling Interface) brings the algebraic modeling style of Python's **PuLP** to the TypeScript and JavaScript ecosystem. Write expressions naturally with tagged templates (`expr`) and solve mathematical optimization problems directly in **Node.js and the browser** using WebAssembly solvers (**HiGHS**, **Z3**, and **GLPK**).
|
|
277
287
|
|
|
278
|
-
|
|
288
|
+
```typescript
|
|
289
|
+
import { Model, expr, maximize } from "@ohtaman/washi";
|
|
290
|
+
|
|
291
|
+
const m = new Model("production");
|
|
279
292
|
|
|
280
|
-
|
|
293
|
+
// 1. Define decision variables with realistic finite bounds (lb / ub)
|
|
294
|
+
const desks = m.integer("desks", { lb: 0, ub: 100 });
|
|
295
|
+
const chairs = m.integer("chairs", { lb: 0, ub: 100 });
|
|
281
296
|
|
|
282
|
-
|
|
283
|
-
|
|
297
|
+
// 2. Set the objective function and constraints
|
|
298
|
+
m.objective(maximize(expr`30 * ${desks} + 50 * ${chairs}`));
|
|
299
|
+
m.constraint(expr`${desks} + 2 * ${chairs} <= 40`, "wood_capacity");
|
|
300
|
+
m.constraint(expr`2 * ${desks} + ${chairs} <= 50`, "labor_capacity");
|
|
301
|
+
|
|
302
|
+
// 3. Solve (built-in HiGHS WebAssembly) and validate the solution
|
|
303
|
+
const status = await m.solve();
|
|
304
|
+
if (m.hasPrimalSolution) {
|
|
305
|
+
const report = m.validateSolution();
|
|
306
|
+
console.log(status, report.valid); // "Optimal", true
|
|
307
|
+
console.log(`Profit: ${m.objectiveValue} (desks=${desks.value}, chairs=${chairs.value})`); // 1100 (desks=20, chairs=10)
|
|
308
|
+
}
|
|
309
|
+
```
|
|
284
310
|
|
|
285
311
|
---
|
|
286
312
|
|
|
287
313
|
## 📦 Installation
|
|
288
314
|
|
|
289
|
-
|
|
315
|
+
The default **HiGHS** solver (LP / MIP / Convex QP) is bundled and works out of the box:
|
|
290
316
|
|
|
291
317
|
```bash [npm]
|
|
292
318
|
npm install @ohtaman/washi
|
|
@@ -304,96 +330,54 @@ yarn add @ohtaman/washi
|
|
|
304
330
|
bun add @ohtaman/washi
|
|
305
331
|
```
|
|
306
332
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
WaSHI ships with **HiGHS** built-in with zero configuration. If you need SMT logic / Unsat Core diagnosis via **Z3**, or compatibility with **GLPK**, install their respective peer dependencies:
|
|
333
|
+
Install optional peer dependencies only if you need **Z3** (SMT logic & Unsat Core diagnosis) or **GLPK**:
|
|
310
334
|
|
|
311
335
|
```bash
|
|
312
|
-
# For SMT logic and Unsat Core conflict diagnosis
|
|
313
|
-
npm install
|
|
314
|
-
|
|
315
|
-
# For GLPK solver (GPL-3.0)
|
|
316
|
-
npm install glpk.js
|
|
336
|
+
npm install z3-solver # For SMT logic and Unsat Core conflict diagnosis
|
|
337
|
+
npm install glpk.js # For GLPK solver (GPL-3.0)
|
|
317
338
|
```
|
|
318
339
|
|
|
319
340
|
---
|
|
320
341
|
|
|
321
|
-
##
|
|
322
|
-
|
|
323
|
-
Here is a step-by-step example of solving a production allocation problem.
|
|
324
|
-
|
|
325
|
-
### 1. Import the Core Classes
|
|
326
|
-
|
|
327
|
-
```typescript
|
|
328
|
-
import { Model, expr, maximize } from "@ohtaman/washi";
|
|
329
|
-
```
|
|
330
|
-
|
|
331
|
-
### 2. Instantiate a Model
|
|
332
|
-
|
|
333
|
-
```typescript
|
|
334
|
-
const model = new Model("WoodworkingProfit");
|
|
335
|
-
```
|
|
342
|
+
## 💡 Why TypeScript & WebAssembly?
|
|
336
343
|
|
|
337
|
-
|
|
344
|
+
- **Zero native setup across Node.js and the browser**: No platform-specific C/C++ builds or separate Python runtimes to manage. A single `npm install` runs the same modeling code across **Node.js APIs, CLIs, and scripts** as well as in the **browser**.
|
|
345
|
+
- **Lightweight embedding with standard Web APIs & UIs**: Go beyond local analysis scripts by shipping **interactive Web tools, mini-games, or Chrome extensions** that anyone can open via a URL with zero backend hosting cost. You can wire mathematical optimization directly into drag-and-drop UIs or **standard browser capabilities**—such as screen/camera capture and speech recognition (Web Speech API)—with no network round-trips or external data transfer.
|
|
346
|
+
- **End-to-end type safety from app data to model**: Prevent common indexing and dictionary-key mistakes with TypeScript's type system. Typed variable dictionaries (`Record<K, Var>`) and variable-reference interpolation (`` expr`30 * ${qty.desk} <= 40` ``) connect directly to your application schemas, preserving compile-time variable/key checks, IDE autocompletion, and symbol renaming.
|
|
347
|
+
- **Strong synergy with AI development & LLM workflows**: Keeping your application and optimization model in a **single TypeScript codebase** lets you validate everything in one test runner—without crossing a Python service boundary—and deploy easily as static assets or a single service. That tight test-and-deploy loop fits AI-assisted coding naturally, while at runtime you can pair flexible LLM extraction with exact solver feasibility and conflict diagnosis (`unsatCore`) in the same runtime.
|
|
338
348
|
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
```typescript
|
|
342
|
-
const x = model.integer("x", { lb: 0 });
|
|
343
|
-
const y = model.integer("y", { lb: 0 });
|
|
344
|
-
```
|
|
345
|
-
|
|
346
|
-
### 4. Set the Objective Function
|
|
347
|
-
|
|
348
|
-
Use the `expr` tagged template literal along with `maximize(...)` or `minimize(...)` to define the goal:
|
|
349
|
-
|
|
350
|
-
```typescript
|
|
351
|
-
// Maximize 30*x + 50*y
|
|
352
|
-
model.objective(maximize(expr`30 * ${x} + 50 * ${y}`));
|
|
353
|
-
```
|
|
354
|
-
|
|
355
|
-
### 5. Add Constraints
|
|
356
|
-
|
|
357
|
-
Use `model.constraint()` with the `expr` tagged template to formulate equality and inequality constraints:
|
|
358
|
-
|
|
359
|
-
```typescript
|
|
360
|
-
model.constraint(expr`${x} + 2 * ${y} <= 40`, "wood_capacity");
|
|
361
|
-
model.constraint(expr`2 * ${x} + ${y} <= 50`, "labor_capacity");
|
|
362
|
-
```
|
|
363
|
-
|
|
364
|
-
### 6. Solve and Extract Results
|
|
349
|
+
---
|
|
365
350
|
|
|
366
|
-
|
|
351
|
+
## 🔍 Core Workflow Essentials
|
|
367
352
|
|
|
368
|
-
|
|
369
|
-
const status = await model.solve();
|
|
353
|
+
Three practical habits cover the essentials shown in the snippet above:
|
|
370
354
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
```
|
|
355
|
+
1. **Set realistic finite bounds (`lb`, `ub`)**: When defining variables with `m.continuous`, `m.integer`, or `m.binary`, finite bounds strengthen MIP presolve and enable automatic tight Big-M derivation for logical constraints.
|
|
356
|
+
2. **Formulate objectives and constraints with `expr`**: Use `` m.objective(maximize(expr`...`)) `` and `` m.constraint(expr`... <= ...`, "name") ``. Naming constraints lets you pinpoint conflicting rules immediately if a model becomes infeasible.
|
|
357
|
+
3. **Check `hasPrimalSolution` and call `validateSolution()`**: After `await m.solve()`, check `m.hasPrimalSolution` before reading `.value` on variables, and call `m.validateSolution()` to independently verify that bounds, integrality, and constraints hold within tolerance.
|
|
375
358
|
|
|
376
359
|
---
|
|
377
360
|
|
|
378
361
|
## 🧭 Next Steps
|
|
379
362
|
|
|
380
|
-
-
|
|
381
|
-
-
|
|
382
|
-
-
|
|
383
|
-
-
|
|
384
|
-
- Browse 9 runnable optimization and SMT recipes in [Examples & API Finder](docs/examples/index.md).
|
|
363
|
+
- **[Modeling Guide](docs/guide/variables.md)**: Array/dictionary variables, aggregations (`sum`, `dot`), logical constraints, and Unsat Core diagnosis
|
|
364
|
+
- **[Examples & API Finder](docs/examples/index.md)**: 11 runnable optimization and SMT recipes—from shift scheduling and portfolio QP to vehicle routing
|
|
365
|
+
- **[Solvers Overview](docs/solvers/index.md)**: Compare built-in HiGHS with optional Z3 and GLPK adapters
|
|
366
|
+
- **[Runtime Environments](docs/getting-started/runtimes.md)**: Setup details for Node.js, Vite / Next.js bundlers, CDNs, and static hosting
|
|
385
367
|
|
|
386
368
|
---
|
|
387
369
|
|
|
388
370
|
# Runtime Environments & Deployment
|
|
389
371
|
|
|
390
|
-
WaSHI runs across JavaScript and TypeScript runtimes—from Node.js, Bun, and Deno to modern bundlers, vanilla browser `<script>` tags
|
|
372
|
+
WaSHI runs across JavaScript and TypeScript runtimes—from Node.js, Bun, and Deno to modern bundlers, static hosting, and vanilla browser `<script>` tags—without native C/C++ compilation.
|
|
391
373
|
|
|
392
374
|
---
|
|
393
375
|
|
|
394
|
-
## 🟢 Node.js
|
|
376
|
+
## 🟢 Node.js, Bun & Deno
|
|
377
|
+
|
|
378
|
+
### Node.js
|
|
395
379
|
|
|
396
|
-
WaSHI supports Node.js (v18+) out of the box with standard ECMAScript Modules (ESM). The built-in HiGHS WebAssembly binary is loaded directly from the installed package
|
|
380
|
+
WaSHI supports Node.js (v18+) out of the box with standard ECMAScript Modules (ESM). The built-in HiGHS WebAssembly binary is loaded directly from the installed package:
|
|
397
381
|
|
|
398
382
|
```typescript
|
|
399
383
|
import { Model, expr, maximize } from "@ohtaman/washi";
|
|
@@ -406,8 +390,10 @@ m.objective(maximize(expr`2 * ${x} + 3 * ${y}`));
|
|
|
406
390
|
m.constraint(expr`${x} + ${y} <= 15`, "capacity");
|
|
407
391
|
|
|
408
392
|
await m.solve();
|
|
409
|
-
|
|
410
|
-
console.log(`
|
|
393
|
+
if (m.hasPrimalSolution) {
|
|
394
|
+
console.log(`Status: ${m.status}, Objective: ${m.objectiveValue}`);
|
|
395
|
+
console.log(`x = ${x.value}, y = ${y.value}`);
|
|
396
|
+
}
|
|
411
397
|
```
|
|
412
398
|
|
|
413
399
|
Run directly with `tsx` or `node`:
|
|
@@ -416,11 +402,33 @@ Run directly with `tsx` or `node`:
|
|
|
416
402
|
npx tsx app.ts
|
|
417
403
|
```
|
|
418
404
|
|
|
405
|
+
### Bun & Deno
|
|
406
|
+
|
|
407
|
+
```bash
|
|
408
|
+
# Bun
|
|
409
|
+
bun add @ohtaman/washi
|
|
410
|
+
bun run app.ts
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
In Deno (v1.30+ with `npm:` specifiers):
|
|
414
|
+
|
|
415
|
+
```typescript
|
|
416
|
+
import { Model, maximize } from "npm:@ohtaman/washi";
|
|
417
|
+
|
|
418
|
+
const m = new Model();
|
|
419
|
+
const x = m.continuous("x", { lb: 0, ub: 10 });
|
|
420
|
+
m.objective(maximize(x));
|
|
421
|
+
await m.solve();
|
|
422
|
+
if (m.hasPrimalSolution) {
|
|
423
|
+
console.log("x =", x.value);
|
|
424
|
+
}
|
|
425
|
+
```
|
|
426
|
+
|
|
419
427
|
---
|
|
420
428
|
|
|
421
|
-
## 🌐 Bundlers (Vite, Webpack, Rollup
|
|
429
|
+
## 🌐 Bundlers & Static Hosting (Vite, Next.js, Webpack, Rollup)
|
|
422
430
|
|
|
423
|
-
### Modern Bundlers
|
|
431
|
+
### Modern Bundlers & Static Deployment
|
|
424
432
|
|
|
425
433
|
When importing `@ohtaman/washi` in a browser bundler, the `browser` export condition resolves automatically:
|
|
426
434
|
|
|
@@ -432,14 +440,16 @@ export async function runOptimization() {
|
|
|
432
440
|
const x = m.continuous("x", { lb: 0, ub: 100 });
|
|
433
441
|
m.objective(maximize(x));
|
|
434
442
|
await m.solve();
|
|
435
|
-
return x.value;
|
|
443
|
+
return m.hasPrimalSolution ? x.value : null;
|
|
436
444
|
}
|
|
437
445
|
```
|
|
438
446
|
|
|
447
|
+
Because both the modeler and solver run on WebAssembly, the static output of `vite build` (or your bundler's production build) can be deployed directly to **GitHub Pages, Cloudflare Pages, Vercel, or Netlify** with no backend server required. (The default HiGHS and GLPK solvers require no special HTTP headers; if you use Z3 in the browser, see [Cross-Origin Isolation](docs/getting-started/cross-origin.md).)
|
|
448
|
+
|
|
439
449
|
#### Background Web Worker Execution (Default)
|
|
440
|
-
In browsers, HiGHS automatically runs inside a dedicated **Web Worker** so
|
|
450
|
+
In browsers, HiGHS automatically runs inside a dedicated **Web Worker** so LP/MIP/QP solves never freeze the main UI thread, animations, or user input, while enabling real-time progress events (`onProgress`) and cancellation (`AbortSignal`).
|
|
441
451
|
|
|
442
|
-
If your environment prohibits Web Workers or you explicitly want
|
|
452
|
+
If your environment prohibits Web Workers or you explicitly want main-thread execution, pass `useWorker: false`:
|
|
443
453
|
|
|
444
454
|
```typescript
|
|
445
455
|
await m.solve({ useWorker: false });
|
|
@@ -478,7 +488,9 @@ export default function OptimizerComponent() {
|
|
|
478
488
|
const x = m.continuous("x", { lb: 0, ub: 50 });
|
|
479
489
|
m.objective(maximize(x));
|
|
480
490
|
await m.solve();
|
|
481
|
-
|
|
491
|
+
if (m.hasPrimalSolution) {
|
|
492
|
+
setVal(x.value);
|
|
493
|
+
}
|
|
482
494
|
};
|
|
483
495
|
|
|
484
496
|
return (
|
|
@@ -494,36 +506,36 @@ export default function OptimizerComponent() {
|
|
|
494
506
|
|
|
495
507
|
## 📦 Browser ESM (`<script type="module">`)
|
|
496
508
|
|
|
497
|
-
|
|
509
|
+
Without a bundler, you can import the pre-built browser ESM module directly from your own static server or an npm CDN:
|
|
498
510
|
|
|
499
511
|
```html
|
|
500
512
|
<script type="module">
|
|
501
|
-
//
|
|
502
|
-
import { Model, expr, maximize } from "
|
|
503
|
-
// Or from an npm CDN once a compatible release is published:
|
|
504
|
-
// import { Model, expr, maximize } from "https://cdn.jsdelivr.net/npm/@ohtaman/washi@<version>/dist/browser/washi.js";
|
|
513
|
+
// From an npm CDN (or "./dist/browser/washi.js" when self-hosted):
|
|
514
|
+
import { Model, expr, maximize } from "https://cdn.jsdelivr.net/npm/@ohtaman/washi@0.6/dist/browser/washi.js";
|
|
505
515
|
// Optional solver plugins:
|
|
506
|
-
// import "
|
|
507
|
-
// import "
|
|
516
|
+
// import "https://cdn.jsdelivr.net/npm/@ohtaman/washi@0.6/dist/browser/solvers/glpk.js";
|
|
517
|
+
// import "https://cdn.jsdelivr.net/npm/@ohtaman/washi@0.6/dist/browser/solvers/z3.js";
|
|
508
518
|
|
|
509
519
|
const m = new Model("BrowserESM");
|
|
510
|
-
const x = m.integer("x", { lb: 0 });
|
|
511
|
-
const y = m.integer("y", { lb: 0 });
|
|
520
|
+
const x = m.integer("x", { lb: 0, ub: 100 });
|
|
521
|
+
const y = m.integer("y", { lb: 0, ub: 100 });
|
|
512
522
|
|
|
513
523
|
m.objective(maximize(expr`30 * ${x} + 50 * ${y}`));
|
|
514
524
|
m.constraint(expr`${x} + 2 * ${y} <= 40`, "wood");
|
|
515
525
|
m.constraint(expr`2 * ${x} + ${y} <= 50`, "labor");
|
|
516
526
|
|
|
517
527
|
await m.solve();
|
|
518
|
-
|
|
528
|
+
if (m.hasPrimalSolution) {
|
|
529
|
+
console.log("Optimal Objective:", m.objectiveValue);
|
|
530
|
+
}
|
|
519
531
|
</script>
|
|
520
532
|
```
|
|
521
533
|
|
|
522
534
|
---
|
|
523
535
|
|
|
524
|
-
## 🟨 Classic `<script>` Tags (IIFE / Standalone
|
|
536
|
+
## 🟨 Classic `<script>` Tags (IIFE / Standalone HTML / CDN)
|
|
525
537
|
|
|
526
|
-
For
|
|
538
|
+
For single-file HTML pages, CodePen, or JSFiddle, load `washi.min.js` via a standard `<script>` tag. All exports are attached to the global `window.Washi` object:
|
|
527
539
|
|
|
528
540
|
```html
|
|
529
541
|
<!DOCTYPE html>
|
|
@@ -536,13 +548,11 @@ For static HTML pages, CodePen, or JSFiddle, load `washi.min.js` via a standard
|
|
|
536
548
|
<h1>WaSHI Browser Optimization</h1>
|
|
537
549
|
<pre id="output">Solving...</pre>
|
|
538
550
|
|
|
539
|
-
<!-- Core WaSHI + HiGHS (MIT)
|
|
540
|
-
<script src="
|
|
541
|
-
<!-- Or via CDN once a compatible release is published: -->
|
|
542
|
-
<!-- <script src="https://cdn.jsdelivr.net/npm/@ohtaman/washi@<version>/dist/browser/washi.min.js"></script> -->
|
|
551
|
+
<!-- Core WaSHI + HiGHS (MIT) via CDN (or "./dist/browser/washi.min.js" when self-hosted) -->
|
|
552
|
+
<script src="https://cdn.jsdelivr.net/npm/@ohtaman/washi@0.6/dist/browser/washi.min.js"></script>
|
|
543
553
|
<!-- Optional plugins (auto-register on window.Washi): -->
|
|
544
|
-
<!-- <script src="
|
|
545
|
-
<!-- <script src="
|
|
554
|
+
<!-- <script src="https://cdn.jsdelivr.net/npm/@ohtaman/washi@0.6/dist/browser/solvers/glpk.min.js"></script> -->
|
|
555
|
+
<!-- <script src="https://cdn.jsdelivr.net/npm/@ohtaman/washi@0.6/dist/browser/solvers/z3.min.js"></script> -->
|
|
546
556
|
|
|
547
557
|
<script>
|
|
548
558
|
(async () => {
|
|
@@ -557,10 +567,12 @@ For static HTML pages, CodePen, or JSFiddle, load `washi.min.js` via a standard
|
|
|
557
567
|
|
|
558
568
|
await m.solve();
|
|
559
569
|
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
570
|
+
if (m.hasPrimalSolution) {
|
|
571
|
+
document.getElementById("output").textContent =
|
|
572
|
+
`Status: ${m.status}\n` +
|
|
573
|
+
`Objective: ${m.objectiveValue}\n` +
|
|
574
|
+
`x = ${x.value}, y = ${y.value}`;
|
|
575
|
+
}
|
|
564
576
|
})();
|
|
565
577
|
</script>
|
|
566
578
|
</body>
|
|
@@ -568,7 +580,7 @@ For static HTML pages, CodePen, or JSFiddle, load `washi.min.js` via a standard
|
|
|
568
580
|
```
|
|
569
581
|
|
|
570
582
|
> [!NOTE]
|
|
571
|
-
> When loading
|
|
583
|
+
> When loading from a cross-origin CDN (such as jsDelivr or unpkg), browsers block direct `new Worker("https://cdn...")` construction under the same-origin policy. On standard web origins (`https://` / `http://`), WaSHI automatically bridges cross-origin workers via a lightweight ES module `Blob` bootstrap (`import "https://..."`). On opaque effective origins (`window.origin === "null"`, such as local `file:` URLs or `sandbox="allow-scripts"` `<iframe>` embeds without `allow-same-origin`), WaSHI automatically runs HiGHS on the main thread when `useWorker` is omitted.
|
|
572
584
|
|
|
573
585
|
---
|
|
574
586
|
|
|
@@ -582,83 +594,81 @@ WaSHI never phones home or falls back to public CDNs. To self-host in an air-gap
|
|
|
582
594
|
- Optional Z3: `solvers/z3.js` / `solvers/z3.min.js`, `solvers/z3-built.js`, `solvers/z3-built.wasm`
|
|
583
595
|
2. **Content Security Policy (CSP) Headers**:
|
|
584
596
|
- Allow WebAssembly compilation (`'wasm-unsafe-eval'` in `script-src`).
|
|
585
|
-
- If using the default Web Worker or cross-origin Blob worker
|
|
597
|
+
- If using the default Web Worker or cross-origin Blob worker bootstrap, allow `worker-src 'self' blob:;`.
|
|
586
598
|
- If `blob:` workers are prohibited by policy, either serve `highs-worker.js` from `'self'` or run on the main thread with `await m.solve({ useWorker: false })`.
|
|
587
599
|
3. **Cross-Origin Isolation (Z3 Only)**:
|
|
588
600
|
- HiGHS and GLPK run in standard browser contexts without special headers.
|
|
589
|
-
- Z3 WASM uses pthreads + `SharedArrayBuffer` and requires `Cross-Origin-Opener-Policy: same-origin` and `Cross-Origin-Embedder-Policy: require-corp` (
|
|
590
|
-
|
|
591
|
-
---
|
|
592
|
-
|
|
593
|
-
## ⚡ Bun & Deno
|
|
594
|
-
|
|
595
|
-
### Bun
|
|
596
|
-
|
|
597
|
-
```bash
|
|
598
|
-
bun add @ohtaman/washi
|
|
599
|
-
bun run app.ts
|
|
600
|
-
```
|
|
601
|
-
|
|
602
|
-
### Deno
|
|
603
|
-
|
|
604
|
-
In Deno (v1.30+ with `npm:` specifiers):
|
|
605
|
-
|
|
606
|
-
```typescript
|
|
607
|
-
import { Model, maximize } from "npm:@ohtaman/washi";
|
|
608
|
-
|
|
609
|
-
const m = new Model();
|
|
610
|
-
const x = m.continuous("x", { lb: 0, ub: 10 });
|
|
611
|
-
m.objective(maximize(x));
|
|
612
|
-
await m.solve();
|
|
613
|
-
console.log("x =", x.value);
|
|
614
|
-
```
|
|
601
|
+
- Z3 WASM uses pthreads + `SharedArrayBuffer` and requires `Cross-Origin-Opener-Policy: same-origin` and `Cross-Origin-Embedder-Policy: require-corp` (`credentialless` works in Chromium/Firefox, whereas Safari requires `require-corp`). See the [Cross-Origin Isolation Guide](docs/getting-started/cross-origin.md) for setup details.
|
|
615
602
|
|
|
616
603
|
---
|
|
617
604
|
|
|
618
605
|
# Cross-Origin Isolation (Z3 & Browsers)
|
|
619
606
|
|
|
620
|
-
When running the **Z3** solver
|
|
607
|
+
When running the **Z3** solver inside a web browser, Cross-Origin Isolation HTTP headers are required so the browser enables `SharedArrayBuffer`.
|
|
608
|
+
|
|
609
|
+
> [!TIP]
|
|
610
|
+
> **Not needed for HiGHS, GLPK, or Node.js**: The built-in **HiGHS** solver and **GLPK** plugin run in standard browser contexts without Cross-Origin Isolation or `SharedArrayBuffer`. Running Z3 in **Node.js** also requires no HTTP headers. You only need this page when using `@ohtaman/washi/solvers/z3` in the browser.
|
|
621
611
|
|
|
622
612
|
---
|
|
623
613
|
|
|
624
614
|
## Why is Cross-Origin Isolation Required?
|
|
625
615
|
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
In modern browsers (Chrome, Firefox, Safari, Edge), `SharedArrayBuffer` is disabled by default to mitigate Spectre-style timing side-channel attacks. To re-enable it, the hosting page must be **Cross-Origin Isolated**.
|
|
616
|
+
Z3's WebAssembly build uses multithreading (pthreads) backed by `SharedArrayBuffer` and Web Workers. Modern browsers (Chrome, Firefox, Safari, Edge) disable `SharedArrayBuffer` by default to mitigate Spectre-style side-channel attacks unless the top-level document opts into **Cross-Origin Isolation** (`window.crossOriginIsolated === true`).
|
|
629
617
|
|
|
630
618
|
---
|
|
631
619
|
|
|
632
620
|
## Required HTTP Response Headers
|
|
633
621
|
|
|
634
|
-
|
|
622
|
+
Serve the top-level HTML document with:
|
|
635
623
|
|
|
636
624
|
```http
|
|
637
625
|
Cross-Origin-Opener-Policy: same-origin
|
|
638
626
|
Cross-Origin-Embedder-Policy: require-corp
|
|
639
627
|
```
|
|
640
628
|
|
|
641
|
-
|
|
629
|
+
If your page loads cross-origin subresources (such as scripts, images, or WASM files from another domain), those responses must also include `Cross-Origin-Resource-Policy: cross-origin` (or CORS headers).
|
|
642
630
|
|
|
643
|
-
|
|
644
|
-
Cross-Origin-
|
|
645
|
-
```
|
|
631
|
+
> [!NOTE]
|
|
632
|
+
> Chromium and Firefox also support `Cross-Origin-Embedder-Policy: credentialless`, which relaxes subresource CORP requirements, **but Safari (WebKit) does not support `credentialless`**. Use `require-corp` if you support Safari / iOS.
|
|
646
633
|
|
|
647
634
|
---
|
|
648
635
|
|
|
649
|
-
##
|
|
636
|
+
## Setup by Environment
|
|
650
637
|
|
|
651
|
-
### 1.
|
|
638
|
+
### 1. Vite (`vite.config.ts`)
|
|
652
639
|
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
640
|
+
Configure both the local dev server (`vite`) and production preview server (`vite preview`):
|
|
641
|
+
|
|
642
|
+
```typescript
|
|
643
|
+
import { defineConfig } from "vite";
|
|
644
|
+
|
|
645
|
+
export default defineConfig({
|
|
646
|
+
server: {
|
|
647
|
+
headers: {
|
|
648
|
+
"Cross-Origin-Opener-Policy": "same-origin",
|
|
649
|
+
"Cross-Origin-Embedder-Policy": "require-corp",
|
|
650
|
+
},
|
|
651
|
+
},
|
|
652
|
+
preview: {
|
|
653
|
+
headers: {
|
|
654
|
+
"Cross-Origin-Opener-Policy": "same-origin",
|
|
655
|
+
"Cross-Origin-Embedder-Policy": "require-corp",
|
|
656
|
+
},
|
|
657
|
+
},
|
|
658
658
|
});
|
|
659
659
|
```
|
|
660
660
|
|
|
661
|
-
### 2.
|
|
661
|
+
### 2. Cloudflare Pages / Netlify (`public/_headers`)
|
|
662
|
+
|
|
663
|
+
Place a `_headers` file in `public/` so it is copied to the root of your build output:
|
|
664
|
+
|
|
665
|
+
```text
|
|
666
|
+
/*
|
|
667
|
+
Cross-Origin-Opener-Policy: same-origin
|
|
668
|
+
Cross-Origin-Embedder-Policy: require-corp
|
|
669
|
+
```
|
|
670
|
+
|
|
671
|
+
### 3. Next.js (`next.config.js`)
|
|
662
672
|
|
|
663
673
|
```javascript
|
|
664
674
|
/** @type {import('next').NextConfig} */
|
|
@@ -679,35 +689,40 @@ const nextConfig = {
|
|
|
679
689
|
module.exports = nextConfig;
|
|
680
690
|
```
|
|
681
691
|
|
|
682
|
-
###
|
|
692
|
+
### 4. Custom Node.js / Express Server
|
|
683
693
|
|
|
684
|
-
|
|
694
|
+
```javascript
|
|
695
|
+
app.use((req, res, next) => {
|
|
696
|
+
res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
|
|
697
|
+
res.setHeader("Cross-Origin-Embedder-Policy", "require-corp");
|
|
698
|
+
next();
|
|
699
|
+
});
|
|
700
|
+
```
|
|
685
701
|
|
|
686
|
-
|
|
702
|
+
### 5. Static Hosting without Header Control (e.g., GitHub Pages)
|
|
687
703
|
|
|
688
|
-
|
|
689
|
-
|
|
704
|
+
Platforms like GitHub Pages do not allow customizing HTTP response headers directly. You can use **`coi-serviceworker`** to inject the isolation headers via a client-side Service Worker:
|
|
705
|
+
|
|
706
|
+
1. Place `coi-serviceworker.js` at your site root.
|
|
707
|
+
2. Load it in your HTML `<head>`:
|
|
690
708
|
|
|
691
709
|
```html
|
|
692
710
|
<script src="/coi-serviceworker.js"></script>
|
|
693
711
|
```
|
|
694
712
|
|
|
695
|
-
|
|
713
|
+
On the first visit, the Service Worker registers and reloads the page once to establish `window.crossOriginIsolated === true`.
|
|
696
714
|
|
|
697
715
|
---
|
|
698
716
|
|
|
699
|
-
##
|
|
700
|
-
|
|
701
|
-
WaSHI's Z3 solver adapter automatically inspects the environment at runtime. If Z3 is invoked in a browser lacking cross-origin isolation, it immediately rejects with a clear, informative error message explaining that COOP/COEP headers or a service worker are required, rather than hanging silently.
|
|
717
|
+
## Runtime Check in WaSHI
|
|
702
718
|
|
|
703
|
-
|
|
704
|
-
> The default **HiGHS** solver engine does not require cross-origin isolation or `SharedArrayBuffer`. It works out of the box in standard browser contexts.
|
|
719
|
+
WaSHI's Z3 adapter checks `crossOriginIsolated` before initializing Z3 in the browser. If Cross-Origin Isolation is missing, `solve()` / `check()` immediately rejects with an actionable error explaining that COOP/COEP headers or a Service Worker are required, rather than hanging silently.
|
|
705
720
|
|
|
706
721
|
---
|
|
707
722
|
|
|
708
723
|
# Variables & Domains
|
|
709
724
|
|
|
710
|
-
Variables represent the mathematical decision variables in your optimization model.
|
|
725
|
+
Variables represent the mathematical decision variables in your optimization or logical model.
|
|
711
726
|
WaSHI provides domain-specific factory methods on the `Model` instance: `m.continuous()`, `m.integer()`, `m.binary()`, and `m.bool()`.
|
|
712
727
|
|
|
713
728
|
---
|
|
@@ -715,7 +730,7 @@ WaSHI provides domain-specific factory methods on the `Model` instance: `m.conti
|
|
|
715
730
|
## Decision Variable Types
|
|
716
731
|
|
|
717
732
|
### 1. Continuous Variables (`m.continuous`)
|
|
718
|
-
Can take any real number value within `[lb, ub]`.
|
|
733
|
+
Can take any real number value within `[lb, ub]`. Used for continuous quantities such as flow rates, weights, budgets, and portfolio allocations:
|
|
719
734
|
|
|
720
735
|
```typescript
|
|
721
736
|
import { Model } from "@ohtaman/washi";
|
|
@@ -727,7 +742,7 @@ const fuel = m.continuous("fuel", { lb: 0, ub: 1000 });
|
|
|
727
742
|
```
|
|
728
743
|
|
|
729
744
|
### 2. Integer Variables (`m.integer`)
|
|
730
|
-
Restricted to
|
|
745
|
+
Restricted to integer values within `[lb, ub]`. Used for discrete counts such as machines, vehicles, or headcount:
|
|
731
746
|
|
|
732
747
|
```typescript
|
|
733
748
|
// Single integer variable bounded in [0, 15]
|
|
@@ -735,48 +750,43 @@ const trucks = m.integer("trucks", { lb: 0, ub: 15 });
|
|
|
735
750
|
```
|
|
736
751
|
|
|
737
752
|
### 3. Binary Variables (`m.binary`)
|
|
738
|
-
Restricted to $\{0, 1\}$ as
|
|
753
|
+
Restricted to $\{0, 1\}$ as numeric values (`lb = 0`, `ub = 1`). Used for 0-1 MIP selection and yes/no decisions that appear in linear sums or objective costs:
|
|
739
754
|
|
|
740
755
|
```typescript
|
|
741
|
-
// Single binary variable
|
|
756
|
+
// Single binary variable (lb = 0, ub = 1 automatically)
|
|
742
757
|
const openWarehouse = m.binary("open_warehouse");
|
|
743
|
-
// lb is automatically 0, ub is automatically 1
|
|
744
758
|
```
|
|
745
759
|
|
|
746
760
|
### 4. Propositional Boolean Variables (`m.bool`)
|
|
747
|
-
Pure boolean
|
|
761
|
+
Pure boolean propositions (`true` / `false`) for logical and SMT constraints:
|
|
748
762
|
|
|
749
763
|
```typescript
|
|
750
|
-
// Single propositional boolean
|
|
751
|
-
const
|
|
752
|
-
const
|
|
764
|
+
// Single propositional boolean variables
|
|
765
|
+
const isAdmin = m.bool("isAdmin");
|
|
766
|
+
const hasMfa = m.bool("hasMfa");
|
|
753
767
|
|
|
754
768
|
// First-class boolean operators: and, or, not, implies, xor
|
|
755
|
-
m.constraint(
|
|
769
|
+
m.constraint(isAdmin.implies(hasMfa), "admin_requires_mfa");
|
|
756
770
|
```
|
|
757
771
|
|
|
758
|
-
###
|
|
759
|
-
Semi-continuous variables take values in $\{0\} \cup [L, U]$ (either zero or between a positive lower and upper bound, useful for minimum production batches or operational thresholds).
|
|
772
|
+
### Choosing Between `m.binary()` and `m.bool()`
|
|
760
773
|
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
const isActive = m.binary("is_active");
|
|
774
|
+
| Feature | `m.binary()` (`Var`) | `m.bool()` (`BoolVar`) |
|
|
775
|
+
| :--- | :--- | :--- |
|
|
776
|
+
| **Solved `.value` type** | `number \| null` (`0` or `1`) | `boolean \| null` (`true` or `false`) |
|
|
777
|
+
| **Linear arithmetic (`expr`, `sum`, `dot`, objective costs)** | ✅ Native (`` expr`30 * ${b}` ``) | ❌ Cannot be multiplied by numbers or summed directly |
|
|
778
|
+
| **Indicator constraints (`m.indicator(b, ...)` )** | ✅ Supported as trigger variable | ❌ Use `` implies(flag, expr`...`) `` instead |
|
|
779
|
+
| **Propositional logic (`and`, `or`, `not`, `implies`, `xor`)** | Via comparison (`` expr`${b} == 1` ``) | ✅ Native first-class methods (`.implies()`, `.and()`) |
|
|
780
|
+
| **Solver execution** | Native 0-1 MIP on HiGHS/GLPK; bounded integer on Z3 | Native boolean proposition on Z3; auto-linearized to 0-1 MIP on HiGHS/GLPK |
|
|
769
781
|
|
|
770
|
-
|
|
771
|
-
m.
|
|
772
|
-
m.constraint(expr`${batch} <= 100 * ${isActive}`, "max_batch_run");
|
|
773
|
-
```
|
|
782
|
+
> [!TIP]
|
|
783
|
+
> Use **`m.binary()`** when a decision participates in arithmetic expressions—such as linear sums ($\sum x_i \ge 3$), objective cost terms ($c_i x_i$), or `m.indicator()`. Use **`m.bool()`** when expressing logical conditions with `implies()`, `and()`, and `or()` without numeric coefficients.
|
|
774
784
|
|
|
775
785
|
---
|
|
776
786
|
|
|
777
787
|
## Multi-Dimensional & Array Creation
|
|
778
788
|
|
|
779
|
-
The domain factory methods (`continuous`, `integer`, `binary`, `bool`)
|
|
789
|
+
The domain factory methods (`continuous`, `integer`, `binary`, `bool`) accept a numeric count or an array of string keys to create 1D arrays, typed dictionaries, and 2D matrices in a single call.
|
|
780
790
|
|
|
781
791
|
### By Count
|
|
782
792
|
Pass a number to create an array of variables:
|
|
@@ -787,15 +797,15 @@ const flags = m.bool(3); // BoolVar[] (p_0, p_1, p_2)
|
|
|
787
797
|
```
|
|
788
798
|
|
|
789
799
|
### 1D Variable Dictionaries
|
|
790
|
-
Pass an array of keys to get a typed dictionary `Record<
|
|
800
|
+
Pass an array of keys to get a typed dictionary `Record<K, Var>` or `Record<K, BoolVar>`:
|
|
791
801
|
|
|
792
802
|
```typescript
|
|
793
|
-
const items = ["laptop", "phone", "tablet"];
|
|
803
|
+
const items = ["laptop", "phone", "tablet"] as const;
|
|
794
804
|
|
|
795
805
|
// Record<"laptop" | "phone" | "tablet", Var>
|
|
796
806
|
const selected = m.binary(items);
|
|
797
807
|
|
|
798
|
-
const perms = m.bool(["read", "write", "execute"]);
|
|
808
|
+
const perms = m.bool(["read", "write", "execute"] as const);
|
|
799
809
|
// perms.read, perms.write, perms.execute
|
|
800
810
|
```
|
|
801
811
|
|
|
@@ -829,16 +839,16 @@ const schedule = m.binary(factories, 7, { prefix: "open_day" });
|
|
|
829
839
|
|
|
830
840
|
| Option | Type | Default | Description |
|
|
831
841
|
| :--- | :--- | :--- | :--- |
|
|
832
|
-
| `lb` | `number` | `0` | Lower bound. Defaults to 0 (non-negative). |
|
|
842
|
+
| `lb` | `number` | `0` | Lower bound. Defaults to `0` (non-negative). |
|
|
833
843
|
| `ub` | `number` | `Infinity` (or `1` for binary) | Upper bound. |
|
|
834
844
|
| `type` | `"Continuous"` \| `"Integer"` \| `"Binary"` | Inferred from method | Underlying variable domain category. |
|
|
835
|
-
| `initialValue` | `number` | `undefined` | Optional primal solution hint (MIP start), kept distinct from solved `variable.value`. |
|
|
845
|
+
| `initialValue` | `number` | `undefined` | Optional primal solution hint (MIP warm start), kept distinct from solved `variable.value`. |
|
|
836
846
|
|
|
837
847
|
---
|
|
838
848
|
|
|
839
849
|
## Primal Initial Values (Warm-Start Hints)
|
|
840
850
|
|
|
841
|
-
When rebuilding a model across iterations
|
|
851
|
+
When rebuilding a model across iterations, you can supply optional primal initial values (`initialValue`) on a subset or all numeric variables (`m.continuous`, `m.integer`, `m.binary`, or `v.initialValue = ...`):
|
|
842
852
|
|
|
843
853
|
```typescript
|
|
844
854
|
const prevValues: Record<string, number | null> = { trucks: 8, open_warehouse: 1 };
|
|
@@ -855,15 +865,15 @@ const openWarehouse2 = m2.binary("open_warehouse", {
|
|
|
855
865
|
const extraCapacity = m2.continuous("extra_capacity", { lb: 0 }); // Partial start: no initialValue needed
|
|
856
866
|
```
|
|
857
867
|
|
|
858
|
-
- **Hint vs. Hard Constraint**: `initialValue` is
|
|
859
|
-
- **Validation & Tolerance (`1e-6`)**: Supplied values must be finite numbers within `[lb - 1e-6, ub + 1e-6]` (and within `1e-6` of an integer
|
|
868
|
+
- **Hint vs. Hard Constraint**: `initialValue` is an optional solver hint (passed to HiGHS via `setSolution`). It does **not** fix the variable or add a constraint, and `variable.value` remains `null` until solved. *(To penalize deviations from a previous plan, see [Formulation Patterns & Tips](docs/guide/formulation-tips.md#minimal-perturbation-rescheduling-vs-warm-starting).)*
|
|
869
|
+
- **Validation & Tolerance (`1e-6`)**: Supplied values must be finite numbers within `[lb - 1e-6, ub + 1e-6]` (and within `1e-6` of an integer for `"Integer"` and `"Binary"` variables). Partial starts are accepted without needing to satisfy every model constraint, though supplying a complete constraint-feasible assignment gives MIP solvers an immediate incumbent bound.
|
|
860
870
|
- **Solver Support**: Supported by **HiGHS** (`capabilities.supportsWarmStart === true`). Solvers without warm-start support (**GLPK**, **Z3**, or custom adapters without `supportsWarmStart: true`) reject `solve()` and `check()` with a descriptive error rather than silently ignoring `initialValue`.
|
|
861
871
|
|
|
862
872
|
---
|
|
863
873
|
|
|
864
874
|
## Accessing Solution Values & Reduced Costs
|
|
865
875
|
|
|
866
|
-
After calling `await m.solve()` or `await m.check()`, assigned variable values
|
|
876
|
+
After calling `await m.solve()` or `await m.check()`, assigned variable values are accessible via `variable.value` (returns `number | null` for `Var`, or `boolean | null` for `BoolVar`) or the bulk accessor `m.getValues()`:
|
|
867
877
|
|
|
868
878
|
```typescript
|
|
869
879
|
await m.solve();
|
|
@@ -871,7 +881,7 @@ await m.solve();
|
|
|
871
881
|
// Single variable primal value
|
|
872
882
|
console.log(`trucks: ${trucks.value}`);
|
|
873
883
|
|
|
874
|
-
// Reduced cost (LP sensitivity analysis
|
|
884
|
+
// Reduced cost (LP sensitivity analysis)
|
|
875
885
|
console.log(`fuel reduced cost: ${fuel.reducedCost}`);
|
|
876
886
|
|
|
877
887
|
// Bulk extraction for 1D or 2D collections (works for both Var and BoolVar collections)
|
|
@@ -886,11 +896,11 @@ const flowValues = m.getValues(ship);
|
|
|
886
896
|
|
|
887
897
|
## Looking Up Variables by Name
|
|
888
898
|
|
|
889
|
-
You can retrieve registered decision variables by name or inspect the total variable count on the model:
|
|
899
|
+
You can retrieve registered decision variables by name or inspect the total numeric variable count on the model:
|
|
890
900
|
|
|
891
901
|
```typescript
|
|
892
902
|
const v = m.getVarByName("trucks"); // Var | undefined
|
|
893
|
-
const b = m.getBoolVarByName("
|
|
903
|
+
const b = m.getBoolVarByName("isAdmin"); // BoolVar | undefined
|
|
894
904
|
console.log("Total numeric variables:", m.numVars);
|
|
895
905
|
```
|
|
896
906
|
|
|
@@ -900,7 +910,7 @@ console.log("Total numeric variables:", m.numVars);
|
|
|
900
910
|
|
|
901
911
|
Mathematical expressions are linear or quadratic combinations of decision variables and numeric coefficients.
|
|
902
912
|
|
|
903
|
-
In JavaScript and TypeScript—which do not support operator overloading—WaSHI provides the **unified tagged template literal (`expr`)**, **`sum()`**, and matrix/vector **`dot()`** to construct algebraic expressions
|
|
913
|
+
In JavaScript and TypeScript—which do not support operator overloading—WaSHI provides the **unified tagged template literal (`expr`)**, **`sum()`**, and matrix/vector **`dot()`** to construct algebraic expressions with full static type safety.
|
|
904
914
|
|
|
905
915
|
| Approach | Example | Infix readability | Type safety & IDE rename | Indexed variables (`flow[i][j]`) |
|
|
906
916
|
| :--- | :--- | :--- | :--- | :--- |
|
|
@@ -910,9 +920,9 @@ In JavaScript and TypeScript—which do not support operator overloading—WaSHI
|
|
|
910
920
|
|
|
911
921
|
---
|
|
912
922
|
|
|
913
|
-
##
|
|
923
|
+
## Tagged Template Literal (`expr`)
|
|
914
924
|
|
|
915
|
-
Similar to parameterized `sql` tagged templates, `expr` receives
|
|
925
|
+
Similar to parameterized `sql` tagged templates, `expr` receives static operator text (`+`, `-`, `*`, `^2`, `<=`, `>=`, `==`) separately from interpolated `${...}` values (`Var`, `LinExpr`, `QuadExpr`, or finite `number`). Variables are bound by object reference rather than resolved by string name, avoiding naming collisions and preserving TypeScript type checking and IDE refactoring.
|
|
916
926
|
|
|
917
927
|
Use `expr` for both arithmetic expressions (`LinExpr | QuadExpr`) and relational constraints (`Constraint`):
|
|
918
928
|
|
|
@@ -935,44 +945,55 @@ const balanceConstraint = expr`${x} + 2 * ${y} <= 40`;
|
|
|
935
945
|
|
|
936
946
|
### Supported Syntax in `expr`
|
|
937
947
|
- **Addition & Subtraction**: `${x} + ${y} - 5`
|
|
938
|
-
- **Scalar Multiplication**: `4 * ${x}`, `${x}
|
|
948
|
+
- **Scalar Multiplication & Division**: `4 * ${x}`, `${x} / 2`
|
|
939
949
|
- **Variable Multiplication (Quadratic terms)**: `${x} * ${y}`, `2 * ${x} * ${x}`
|
|
940
950
|
- **Powers & Exponentiation**: `${x}^2`, `${x}**2`
|
|
941
|
-
- **Unary Negation**: `-${x} + 10`
|
|
951
|
+
- **Unary Plus & Negation**: `-${x} + 10`, `+${x}`
|
|
942
952
|
- **Parentheses**: `2 * (${x} + ${y})`
|
|
943
|
-
- **
|
|
953
|
+
- **Aggregations (`sum` & `dot`)**: `sum(${xVec}) <= 100`, `dot(${costs}, ${xVec})`, `sum(${rows}, ${(r) => grid[r][col]})`
|
|
954
|
+
- **Element-Wise Container Arithmetic**: `sum((${price} - ${cost}) * ${qty})`, `sum((${xVec} - ${target})^2)`, `dot(${price} - ${cost}, ${qty})`
|
|
955
|
+
- **Nested Expressions**: `expr` interpolates variables, numbers, `Array` / `Record` containers (inside `sum` or `dot`), and nested `LinExpr` / `QuadExpr` objects.
|
|
944
956
|
|
|
945
957
|
> [!NOTE]
|
|
946
958
|
> For relational comparisons (`<=`, `>=`, `==`), `expr` (e.g., ``expr`${x} + 2 * ${y} <= 40` ``) returns a first-class `Constraint`.
|
|
947
|
-
> For arithmetic expressions without comparison operators, `expr` uniformly handles linear and quadratic expressions (up to degree 2): if quadratic terms are present, `expr` returns a `QuadExpr`; otherwise, it returns a `LinExpr`. Polynomial terms of degree $> 2$ (e.g
|
|
959
|
+
> For arithmetic expressions without comparison operators, `expr` uniformly handles linear and quadratic expressions (up to degree 2): if quadratic terms are present, `expr` returns a `QuadExpr`; otherwise, it returns a `LinExpr`. Interpolated `Array` or `Record` containers must be reduced to a scalar via `sum(...)` or `dot(..., ...)`. Polynomial terms of degree $> 2$ (e.g., ``${x} * ${y} * ${z}``, ``${x}^3``) and non-linear constraints (e.g., ``${x} * ${y} <= 10``) throw an error because LP/MIP/QP formulations require linear constraints and at most quadratic objectives.
|
|
948
960
|
|
|
949
961
|
---
|
|
950
962
|
|
|
951
|
-
##
|
|
963
|
+
## High-Performance Summation (`sum`)
|
|
952
964
|
|
|
953
|
-
|
|
965
|
+
Sum 1D or 2D `Array`, `Iterable`, or `Record` containers directly inside `` expr`sum(...)` `` or via the standalone `sum()` helper:
|
|
954
966
|
|
|
955
967
|
```typescript
|
|
956
|
-
import { sum } from "@ohtaman/washi";
|
|
968
|
+
import { expr, sum } from "@ohtaman/washi";
|
|
957
969
|
|
|
958
|
-
const items = ["A", "B", "C"];
|
|
970
|
+
const items = ["A", "B", "C"] as const;
|
|
959
971
|
const weights = { A: 10, B: 20, C: 30 };
|
|
960
972
|
const x = m.binary(items);
|
|
961
973
|
|
|
962
|
-
// 1.
|
|
974
|
+
// 1. Direct Record or Array summation inside expr (no Object.values() needed):
|
|
975
|
+
m.constraint(expr`sum(${x}) <= 2`, "max_selected");
|
|
976
|
+
|
|
977
|
+
// 2. Element-wise arithmetic inside expr`sum(...)`:
|
|
978
|
+
const weightedExpr = expr`sum(${weights} * ${x})`;
|
|
979
|
+
|
|
980
|
+
// 3. Transform elements with a mapping callback (standalone or inside expr):
|
|
963
981
|
const totalWeight = sum(items, (item) => x[item].mul(weights[item]));
|
|
964
982
|
|
|
965
|
-
//
|
|
966
|
-
const count = sum(
|
|
983
|
+
// 4. Standalone 1D/2D Record or Array summation:
|
|
984
|
+
const count = sum(x);
|
|
967
985
|
```
|
|
968
986
|
|
|
969
|
-
`sum()` runs in $O(N)$ linear time without AST overhead.
|
|
987
|
+
`sum()` runs in $O(N)$ linear time without AST parsing overhead.
|
|
988
|
+
|
|
989
|
+
> [!TIP]
|
|
990
|
+
> **Avoid $O(N^2)$ `expr` Re-Assignment Inside Loops**: Because `LinExpr` and `expr` results are immutable, accumulating terms one by one via ``total = expr`${total} + ${x[i]}` `` inside a loop copies all previously accumulated terms on every iteration ($O(N^2)$ time and memory). Pass arrays or records to `sum(x)` or `dot(coeffs, x)` for $O(N)$ construction.
|
|
970
991
|
|
|
971
992
|
---
|
|
972
993
|
|
|
973
|
-
##
|
|
994
|
+
## Dot Product (`dot`)
|
|
974
995
|
|
|
975
|
-
The `dot(a, b)` helper computes linear combinations of numeric coefficients (`number`) and variables or linear expressions (`Var | LinExpr`) in either operand order (`dot(coeffs, vars)` or `dot(vars, coeffs)`).
|
|
996
|
+
The `dot(a, b)` helper (usable both inside `` expr`dot(${a}, ${b})` `` and as a standalone function `dot(a, b)`) computes linear combinations of numeric coefficients (`number`) and variables or linear expressions (`Var | LinExpr`) in either operand order (`dot(coeffs, vars)` or `dot(vars, coeffs)`).
|
|
976
997
|
|
|
977
998
|
Supported 1D and 2D container shapes:
|
|
978
999
|
- **1D Arrays**: `number[]` $\times$ `(Var | LinExpr)[]`
|
|
@@ -985,41 +1006,74 @@ Mismatched array lengths, missing dictionary keys, or non-finite/non-linear leav
|
|
|
985
1006
|
|
|
986
1007
|
### 1D Vector Dot Product
|
|
987
1008
|
```typescript
|
|
988
|
-
import { dot } from "@ohtaman/washi";
|
|
1009
|
+
import { expr, dot } from "@ohtaman/washi";
|
|
989
1010
|
|
|
990
1011
|
const profits = { desk: 30, chair: 50 };
|
|
991
|
-
const vars = m.integer(["desk", "chair"]);
|
|
1012
|
+
const vars = m.integer(["desk", "chair"] as const);
|
|
992
1013
|
|
|
993
|
-
// Computes 30 * vars.desk + 50 * vars.chair
|
|
994
|
-
const totalProfit = dot(profits, vars)
|
|
1014
|
+
// Computes 30 * vars.desk + 50 * vars.chair inside expr (or via standalone dot(profits, vars))
|
|
1015
|
+
const totalProfit = expr`dot(${profits}, ${vars})`;
|
|
995
1016
|
```
|
|
996
1017
|
|
|
997
|
-
### 2D Matrix
|
|
1018
|
+
### 2D Matrix Inner Product (Frobenius Inner Product)
|
|
998
1019
|
Works directly with 2D arrays (`m.continuous(rows, cols)`), nested dictionaries (`m.continuous(rowKeys, colKeys)`), or mixed 2D containers:
|
|
999
1020
|
|
|
1000
1021
|
```typescript
|
|
1001
|
-
const factories = ["Tokyo", "Osaka"];
|
|
1002
|
-
const stores = ["StoreA", "StoreB"];
|
|
1022
|
+
const factories = ["Tokyo", "Osaka"] as const;
|
|
1023
|
+
const stores = ["StoreA", "StoreB"] as const;
|
|
1003
1024
|
|
|
1004
1025
|
const costs = {
|
|
1005
1026
|
Tokyo: { StoreA: 4, StoreB: 7 },
|
|
1006
1027
|
Osaka: { StoreA: 8, StoreB: 3 },
|
|
1007
1028
|
};
|
|
1008
1029
|
|
|
1009
|
-
const ship = m.continuous(factories, stores);
|
|
1030
|
+
const ship = m.continuous(factories, stores, { lb: 0 });
|
|
1010
1031
|
|
|
1011
|
-
// Total shipping cost
|
|
1012
|
-
|
|
1032
|
+
// Total shipping cost across all routes (equivalent to expr`sum(${costs} * ${ship})`):
|
|
1033
|
+
const totalShippingCost = expr`dot(${costs}, ${ship})`;
|
|
1013
1034
|
```
|
|
1014
1035
|
|
|
1036
|
+
> [!NOTE]
|
|
1037
|
+
> **2D `dot(A, B)` is the Frobenius Inner Product**: For 2D matrices or nested records, `dot(A, B)` computes the scalar **Frobenius inner product** $\langle A, B \rangle_F = \sum_{i,j} A_{i,j} B_{i,j}$ (the sum of element-wise products across all rows and columns, equivalent to `` expr`sum(${A} * ${B})` ``), **not** the matrix-matrix product $(AB)_{i,k} = \sum_j A_{i,j} B_{j,k}$.
|
|
1038
|
+
|
|
1015
1039
|
---
|
|
1016
1040
|
|
|
1017
|
-
##
|
|
1041
|
+
## Composing Aggregations in `expr`
|
|
1042
|
+
|
|
1043
|
+
### Separating Operators and Data
|
|
1044
|
+
|
|
1045
|
+
Write mathematical operators, `sum(...)`, `dot(..., ...)`, and comparison symbols (`<=`, `>=`, `==`) in the template string, and pass variables, scalars, and containers (`Array` / `Record`) via `${...}`:
|
|
1018
1046
|
|
|
1019
|
-
|
|
1047
|
+
```typescript
|
|
1048
|
+
// Nested template calls and Object.values() (verbose)
|
|
1049
|
+
const totalProfit1 = expr`${dot(profits, vars)}`;
|
|
1050
|
+
const cap1 = expr`${sum(Object.values(vars))} <= 100`;
|
|
1051
|
+
|
|
1052
|
+
// Direct sum() and dot() inside expr (recommended)
|
|
1053
|
+
const totalProfit2 = expr`dot(${profits}, ${vars})`;
|
|
1054
|
+
const cap2 = expr`sum(${vars}) <= 100`;
|
|
1055
|
+
```
|
|
1056
|
+
|
|
1057
|
+
### Common `expr` Aggregation Syntax
|
|
1058
|
+
|
|
1059
|
+
| Operation | Mathematical Formula | WaSHI Expression |
|
|
1060
|
+
| :--- | :--- | :--- |
|
|
1061
|
+
| **Total sum** (1D/2D `Array` or `Record`) | $\sum_i x_i \le C$ | `` m.constraint(expr`sum(${x}) <= ${C}`) `` |
|
|
1062
|
+
| **Inner product** (1D vector or 2D Frobenius) | $\sum_i c_i x_i \le B$ | `` m.constraint(expr`dot(${c}, ${x}) <= ${B}`) `` |
|
|
1063
|
+
| **Element-wise difference & product** | $\sum_i (p_i - c_i) \cdot x_i$ | `` expr`sum((${price} - ${cost}) * ${x})` `` |
|
|
1064
|
+
| **Sum of squared deviations (QP)** | $\sum_i (x_i - t_i)^2$ | `` expr`sum((${x} - ${target})^2)` `` |
|
|
1065
|
+
| **Element-wise constraints** ($\forall i \in I$) | $a_i x_i \le b_i \quad (\forall i)$ | `` m.constraint(items.map((i) => expr`${a[i]} * ${x[i]} <= ${b[i]}`)) `` |
|
|
1066
|
+
| **2D row sums** (for each row $f$) | $\sum_s X_{f,s} \le S_f \quad (\forall f)$ | `` m.constraint(factories.map((f) => expr`sum(${ship[f]}) <= ${supply[f]}`)) `` |
|
|
1067
|
+
| **2D column sums** (for each column $s$) | $\sum_f X_{f,s} \ge D_s \quad (\forall s)$ | `` m.constraint(stores.map((s) => expr`sum(${factories}, ${(f) => ship[f][s]}) >= ${demand[s]}`)) `` |
|
|
1068
|
+
|
|
1069
|
+
---
|
|
1070
|
+
|
|
1071
|
+
## Quadratic Expressions (`QuadExpr` & `expr`)
|
|
1072
|
+
|
|
1073
|
+
WaSHI supports Convex Quadratic Programming (QP) objectives via the HiGHS solver. Quadratic expressions can be written using the `expr` template literal or constructed programmatically with `QuadExpr` and `.mul()`.
|
|
1020
1074
|
|
|
1021
1075
|
### 1. Using `expr` (Recommended)
|
|
1022
|
-
|
|
1076
|
+
Write quadratic terms with `*`, `^2`, or `**2`:
|
|
1023
1077
|
|
|
1024
1078
|
```typescript
|
|
1025
1079
|
import { Model, expr, minimize } from "@ohtaman/washi";
|
|
@@ -1036,7 +1090,7 @@ m.constraint(expr`${x} + ${y} >= 4`);
|
|
|
1036
1090
|
```
|
|
1037
1091
|
|
|
1038
1092
|
### 2. Method Chaining with `.mul()` or `QuadExpr`
|
|
1039
|
-
|
|
1093
|
+
You can also construct quadratic expressions programmatically:
|
|
1040
1094
|
|
|
1041
1095
|
```typescript
|
|
1042
1096
|
import { Model, QuadExpr, minimize } from "@ohtaman/washi";
|
|
@@ -1045,12 +1099,11 @@ const m = new Model();
|
|
|
1045
1099
|
const x = m.continuous("x", { lb: 0 });
|
|
1046
1100
|
const y = m.continuous("y", { lb: 0 });
|
|
1047
1101
|
|
|
1048
|
-
// 1. Construct quadratic terms with .mul()
|
|
1049
|
-
// Represents x * y + 2 * x^2
|
|
1102
|
+
// 1. Construct quadratic terms with .mul() (x * y + 2 * x^2)
|
|
1050
1103
|
const quad = x.mul(y).add(x.mul(x).mul(2));
|
|
1051
1104
|
|
|
1052
|
-
// 2. Build portfolio
|
|
1053
|
-
const assets = ["Tech", "Bonds"];
|
|
1105
|
+
// 2. Build portfolio variance objective: w^T * Sigma * w
|
|
1106
|
+
const assets = ["Tech", "Bonds"] as const;
|
|
1054
1107
|
const w = m.continuous(assets, { lb: 0, ub: 1 });
|
|
1055
1108
|
|
|
1056
1109
|
let risk = new QuadExpr();
|
|
@@ -1061,13 +1114,22 @@ risk = risk.add(w.Bonds.mul(w.Bonds).mul(0.01));
|
|
|
1061
1114
|
m.objective(minimize(risk));
|
|
1062
1115
|
```
|
|
1063
1116
|
|
|
1117
|
+
> [!NOTE]
|
|
1118
|
+
> **Convexity Requirement ($\mathbf{Q} \succeq 0$)**: QP solvers (including HiGHS) require the quadratic objective matrix $\mathbf{Q}$ to be **positive semi-definite (PSD)** when minimizing (or negative semi-definite when maximizing). Sum-of-squares terms ($\sum_i (x_i - t_i)^2$) and covariance matrices ($\mathbf{w}^\top \boldsymbol{\Sigma} \mathbf{w}$) are guaranteed PSD, whereas indefinite products over continuous variables (such as minimizing $x \cdot y$) are non-convex and rejected by the solver. *(To linearize the product of two binary variables $x_A \cdot x_B$ in MILP, see [Formulation Patterns & Tips](docs/guide/formulation-tips.md#product-of-two-binary-variables).)*
|
|
1119
|
+
|
|
1064
1120
|
---
|
|
1065
1121
|
|
|
1066
|
-
##
|
|
1122
|
+
## Evaluating Expressions After Solving (`.value`)
|
|
1067
1123
|
|
|
1068
|
-
Both `LinExpr` and `QuadExpr` expose a `.value` getter (`number | null`). Once a model has been solved and all referenced variables have finite primal values, you can evaluate any sub-expression
|
|
1124
|
+
Both `LinExpr` and `QuadExpr` expose a `.value` getter (`number | null`). Once a model has been solved and all referenced variables have finite primal values, you can evaluate any sub-expression directly:
|
|
1069
1125
|
|
|
1070
1126
|
```typescript
|
|
1127
|
+
import { Model, expr, maximize } from "@ohtaman/washi";
|
|
1128
|
+
|
|
1129
|
+
const m = new Model();
|
|
1130
|
+
const x = m.integer("x", { lb: 0 });
|
|
1131
|
+
const y = m.integer("y", { lb: 0 });
|
|
1132
|
+
|
|
1071
1133
|
const revenue = expr`30 * ${x} + 50 * ${y}`;
|
|
1072
1134
|
const laborUsed = expr`2 * ${x} + ${y}`;
|
|
1073
1135
|
|
|
@@ -1085,7 +1147,7 @@ If the model has not been solved (or any variable in the expression has `value =
|
|
|
1085
1147
|
|
|
1086
1148
|
---
|
|
1087
1149
|
|
|
1088
|
-
##
|
|
1150
|
+
## Fluent Arithmetic & Relational Methods
|
|
1089
1151
|
|
|
1090
1152
|
In addition to `expr`, `Var`, `LinExpr`, and `QuadExpr` provide immutable fluent methods for programmatic expression building:
|
|
1091
1153
|
|
|
@@ -1105,17 +1167,17 @@ In addition to `expr`, `Var`, `LinExpr`, and `QuadExpr` provide immutable fluent
|
|
|
1105
1167
|
# Constraints (`m.constraint` & `m.indicator`)
|
|
1106
1168
|
|
|
1107
1169
|
Constraints restrict the feasible solution space of your mathematical or logical model.
|
|
1108
|
-
In WaSHI, both linear arithmetic constraints and propositional
|
|
1170
|
+
In WaSHI, both linear arithmetic constraints and propositional logic formulas are registered using **`m.constraint()`**:
|
|
1109
1171
|
|
|
1110
1172
|
```typescript
|
|
1111
|
-
m.constraint(expr`${
|
|
1173
|
+
m.constraint(expr`${x} + 2 * ${y} <= 40`, "wood_limit");
|
|
1112
1174
|
```
|
|
1113
1175
|
|
|
1114
1176
|
---
|
|
1115
1177
|
|
|
1116
|
-
##
|
|
1178
|
+
## Algebraic Linear Constraints (`expr`)
|
|
1117
1179
|
|
|
1118
|
-
The `expr` tagged template literal
|
|
1180
|
+
The `expr` tagged template literal allows writing equations and inequalities with standard mathematical notation:
|
|
1119
1181
|
|
|
1120
1182
|
```typescript
|
|
1121
1183
|
import { Model, expr } from "@ohtaman/washi";
|
|
@@ -1134,13 +1196,15 @@ m.constraint(expr`3 * ${x} + ${y} >= 20`, "min_demand");
|
|
|
1134
1196
|
m.constraint(expr`${x} + ${y} == 30`, "exact_quota");
|
|
1135
1197
|
```
|
|
1136
1198
|
|
|
1199
|
+
`m.constraint()` returns the registered `Constraint` (or `BoolExpr` for logical formulas), or an array when passed a batch array.
|
|
1200
|
+
|
|
1137
1201
|
### Supported Comparison Operators
|
|
1138
1202
|
- `<=`: Upper bound constraint
|
|
1139
1203
|
- `>=`: Lower bound constraint
|
|
1140
1204
|
- `==` or `=`: Exact equality constraint
|
|
1141
1205
|
|
|
1142
1206
|
### Batch Registration
|
|
1143
|
-
|
|
1207
|
+
Pass an array of constraints to `m.constraint([...])` to register them transactionally in a single call:
|
|
1144
1208
|
|
|
1145
1209
|
```typescript
|
|
1146
1210
|
m.constraint([expr`${x} <= 10`, expr`${y} <= 20`]);
|
|
@@ -1148,50 +1212,51 @@ m.constraint([expr`${x} <= 10`, expr`${y} <= 20`]);
|
|
|
1148
1212
|
|
|
1149
1213
|
---
|
|
1150
1214
|
|
|
1151
|
-
##
|
|
1215
|
+
## Naming Constraints
|
|
1152
1216
|
|
|
1153
|
-
|
|
1217
|
+
Pass a unique, descriptive name as the second argument of `m.constraint(c, name)`:
|
|
1154
1218
|
|
|
1155
1219
|
```typescript
|
|
1156
1220
|
m.constraint(expr`${x} + ${y} <= 100`, "budget_cap");
|
|
1157
1221
|
```
|
|
1158
1222
|
|
|
1159
1223
|
### Why Name Constraints?
|
|
1160
|
-
1. **Model Inspection (`m.toLpString()`)**: Exported CPLEX LP strings retain these descriptive names (sanitized if needed) so you can inspect the
|
|
1161
|
-
2. **Conflict Diagnosis (`conflicts` / `unsatCore`)**: When constraints contradict each other,
|
|
1224
|
+
1. **Model Inspection (`m.toLpString()`)**: Exported CPLEX LP strings retain these descriptive names (sanitized if needed) so you can inspect the generated formulation.
|
|
1225
|
+
2. **Conflict Diagnosis (`conflicts` / `unsatCore`)**: When constraints contradict each other, `m.check()` returns the conflicting constraint names (via built-in HiGHS elastic relaxation or Z3 `unsatCore`).
|
|
1162
1226
|
3. **Direct Lookup (`m.getConstraintByName()`)**: Retrieve a constraint by name after solving to inspect its `dual` or `slack` value.
|
|
1163
1227
|
|
|
1164
1228
|
---
|
|
1165
1229
|
|
|
1166
|
-
##
|
|
1230
|
+
## Aggregated Constraints (`sum` & `dot`)
|
|
1167
1231
|
|
|
1168
|
-
Use `sum()`
|
|
1232
|
+
Use `sum(...)` and `dot(..., ...)` directly inside `expr` to express linear aggregations over arrays and dictionaries cleanly:
|
|
1169
1233
|
|
|
1170
1234
|
```typescript
|
|
1171
|
-
import { Model,
|
|
1235
|
+
import { Model, expr } from "@ohtaman/washi";
|
|
1172
1236
|
|
|
1173
|
-
const
|
|
1237
|
+
const m = new Model();
|
|
1238
|
+
const items = ["A", "B", "C"] as const;
|
|
1174
1239
|
const weights = { A: 5, B: 10, C: 15 };
|
|
1175
1240
|
const selected = m.binary(items);
|
|
1176
1241
|
|
|
1242
|
+
// Select at most 2 items
|
|
1243
|
+
m.constraint(expr`sum(${selected}) <= 2`, "max_items");
|
|
1244
|
+
|
|
1177
1245
|
// Total weight cannot exceed capacity 25
|
|
1178
|
-
m.constraint(
|
|
1179
|
-
expr`${sum(items, (i) => selected[i].mul(weights[i]))} <= 25`,
|
|
1180
|
-
"capacity_limit"
|
|
1181
|
-
);
|
|
1246
|
+
m.constraint(expr`dot(${weights}, ${selected}) <= 25`, "capacity_limit");
|
|
1182
1247
|
```
|
|
1183
1248
|
|
|
1184
1249
|
---
|
|
1185
1250
|
|
|
1186
|
-
##
|
|
1251
|
+
## Conditional / Indicator Constraints (`m.indicator`)
|
|
1187
1252
|
|
|
1188
|
-
When a linear constraint should only take effect when a binary decision variable
|
|
1253
|
+
When a linear constraint should only take effect when a binary decision variable (`m.binary()`) equals `1` (or `0`), use `m.indicator()`:
|
|
1189
1254
|
|
|
1190
1255
|
```typescript
|
|
1191
1256
|
const useDiscount = m.binary("use_discount");
|
|
1192
1257
|
const cartTotal = m.continuous("cart_total", { lb: 0, ub: 10_000 });
|
|
1193
1258
|
|
|
1194
|
-
// If useDiscount == 1,
|
|
1259
|
+
// If useDiscount == 1, enforce cartTotal >= 100
|
|
1195
1260
|
// WaSHI automatically derives a safe, tight Big-M from the variable bounds
|
|
1196
1261
|
m.indicator(useDiscount, expr`${cartTotal} >= 100`, {
|
|
1197
1262
|
activeValue: 1,
|
|
@@ -1201,16 +1266,16 @@ m.indicator(useDiscount, expr`${cartTotal} >= 100`, {
|
|
|
1201
1266
|
|
|
1202
1267
|
### Safe Automatic Big-M Derivation & Bounds
|
|
1203
1268
|
|
|
1204
|
-
When `options.M` is omitted, WaSHI automatically derives a
|
|
1269
|
+
When `options.M` is omitted, WaSHI automatically derives a minimal valid Big-M value from finite variable bounds (`lb` and `ub`):
|
|
1205
1270
|
- For `<=` constraints, variables with positive coefficients require finite upper bounds (`ub`), and negative coefficients require finite lower bounds (`lb`).
|
|
1206
1271
|
- For `>=` constraints, variables with positive coefficients require finite lower bounds (`lb`), and negative coefficients require finite upper bounds (`ub`).
|
|
1207
|
-
- For `==` constraints,
|
|
1272
|
+
- For `==` constraints, bounds are derived separately for both the `<=` and `>=` directions (generating `name_le` and `name_ge`).
|
|
1208
1273
|
|
|
1209
|
-
If any referenced variable lacks the required finite
|
|
1274
|
+
If any referenced variable lacks the required finite bound, `m.indicator()` throws an error prompting for finite bounds or an explicit `options.M`.
|
|
1210
1275
|
|
|
1211
|
-
### Explicit Override
|
|
1276
|
+
### Explicit `M` Override
|
|
1212
1277
|
|
|
1213
|
-
You can override the derived Big-M by
|
|
1278
|
+
You can override the derived Big-M by passing `{ M: number }`:
|
|
1214
1279
|
|
|
1215
1280
|
```typescript
|
|
1216
1281
|
m.indicator(useDiscount, expr`${cartTotal} >= 100`, {
|
|
@@ -1220,15 +1285,13 @@ m.indicator(useDiscount, expr`${cartTotal} >= 100`, {
|
|
|
1220
1285
|
```
|
|
1221
1286
|
|
|
1222
1287
|
> [!TIP]
|
|
1223
|
-
> **
|
|
1224
|
-
> An $M$ value that is too small can inadvertently cut off valid solutions. Conversely, an excessively large $M$ (e.g., $10^9$) causes poor matrix conditioning and numerical instability in MIP solvers.
|
|
1225
|
-
> By setting explicit, realistic variable bounds (`lb` and `ub`), WaSHI derives the tightest possible safe Big-M automatically.
|
|
1288
|
+
> **Prefer Finite Variable Bounds Over Arbitrary Large `M`**: An $M$ value that is too small can cut off valid feasible solutions, whereas an excessively large $M$ (e.g., $10^9$) degrades numerical conditioning in MIP solvers. Setting realistic `lb` and `ub` bounds on variables lets WaSHI compute the tightest safe Big-M automatically.
|
|
1226
1289
|
|
|
1227
1290
|
---
|
|
1228
1291
|
|
|
1229
|
-
##
|
|
1292
|
+
## Propositional Logic & SMT Constraints (`BoolVar`, `and`, `or`, `not`, `implies`, `xor`)
|
|
1230
1293
|
|
|
1231
|
-
|
|
1294
|
+
WaSHI provides first-class logical operators to combine propositional boolean variables (`m.bool()`) and linear constraints (`Constraint`). Logical formulas are solved natively by the **Z3** SMT adapter (`@ohtaman/washi/solvers/z3`) and automatically linearized to 0-1 MIP constraints on **HiGHS** and **GLPK**:
|
|
1232
1295
|
|
|
1233
1296
|
```typescript
|
|
1234
1297
|
import { Model, expr, and, or, not, implies, xor } from "@ohtaman/washi";
|
|
@@ -1237,14 +1300,14 @@ import "@ohtaman/washi/solvers/z3";
|
|
|
1237
1300
|
const m = new Model({ solver: "z3" });
|
|
1238
1301
|
const isAdmin = m.bool("isAdmin");
|
|
1239
1302
|
const hasMfa = m.bool("hasMfa");
|
|
1240
|
-
const
|
|
1303
|
+
const clearance = m.integer("clearance", { lb: 0, ub: 5 });
|
|
1241
1304
|
|
|
1242
1305
|
// Method syntax on BoolVar / BoolOp:
|
|
1243
1306
|
m.constraint(isAdmin.implies(hasMfa), "admin_requires_mfa");
|
|
1244
1307
|
|
|
1245
1308
|
// Mixing boolean propositions with linear arithmetic comparisons:
|
|
1246
1309
|
m.constraint(
|
|
1247
|
-
implies(isAdmin, expr`${
|
|
1310
|
+
implies(isAdmin, expr`${clearance} >= 4`),
|
|
1248
1311
|
"admin_clearance_level"
|
|
1249
1312
|
);
|
|
1250
1313
|
|
|
@@ -1259,26 +1322,31 @@ m.constraint(and(or(isAdmin, hasMfa), not(xor(isAdmin, hasMfa))), "policy");
|
|
|
1259
1322
|
| **Trigger variable** | Numeric `Binary` variable (`m.binary()`) | `BoolVar` (`m.bool()`), `BoolOp`, or `Constraint` |
|
|
1260
1323
|
| **Consequent** | Linear `Constraint` (`<=`, `>=`, `==`) | `BoolVar`, `BoolOp`, or linear `Constraint` |
|
|
1261
1324
|
| **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) |
|
|
1262
|
-
| **
|
|
1325
|
+
| **Primary Use Case** | MIP optimization models with arithmetic binary variables (`m.solve()`) | Logical rule systems and SMT feasibility (`m.check()`) |
|
|
1326
|
+
|
|
1327
|
+
> [!NOTE]
|
|
1328
|
+
> **General Inequality Antecedents in `implies()`**: On **Z3** (`solver: "z3"`), the antecedent `a` in `implies(a, b)` can be any linear inequality—for example, ``implies(expr`${clearance} >= 4`, hasMfa)`` using `clearance` and `hasMfa` above. On MIP solvers (**HiGHS** / **GLPK**), conditional constraints should use a `BoolVar` (`m.bool()`) antecedent or `m.indicator(binVar, constraint)` with a `Binary` trigger variable.
|
|
1263
1329
|
|
|
1264
1330
|
---
|
|
1265
1331
|
|
|
1266
|
-
##
|
|
1332
|
+
## Dual Values, Shadow Prices & Lookups
|
|
1267
1333
|
|
|
1268
|
-
For continuous Linear Programming (LP) problems, query the
|
|
1334
|
+
For continuous Linear Programming (LP) problems, query the shadow price (`dual`) and surplus (`slack`) on each constraint (returned by `m.constraint()` or looked up via `m.getConstraintByName()`):
|
|
1269
1335
|
|
|
1270
1336
|
```typescript
|
|
1271
|
-
m.constraint(expr`${x} + 2 * ${y} <= 40`, "wood");
|
|
1337
|
+
const woodConstraint = m.constraint(expr`${x} + 2 * ${y} <= 40`, "wood");
|
|
1272
1338
|
|
|
1273
1339
|
await m.solve();
|
|
1274
1340
|
|
|
1275
|
-
|
|
1276
|
-
console.log("
|
|
1277
|
-
console.log("Slack surplus:", woodConstraint?.slack);
|
|
1341
|
+
console.log("Shadow price (dual):", woodConstraint.dual);
|
|
1342
|
+
console.log("Slack surplus:", woodConstraint.slack);
|
|
1278
1343
|
console.log("Total linear constraints:", m.numConstrs);
|
|
1279
1344
|
```
|
|
1280
1345
|
|
|
1281
|
-
For logical constraints
|
|
1346
|
+
For named logical constraints, use `m.getLogicalConstraintByName(name)` to look up the registered `LogicalConstraintItem`.
|
|
1347
|
+
|
|
1348
|
+
> [!TIP]
|
|
1349
|
+
> For modeling techniques such as **soft constraints with slack variables** and **symmetry breaking**, see [Formulation Patterns & Optimization Tips](docs/guide/formulation-tips.md#_3-constraint-design-numerical-conditioning).
|
|
1282
1350
|
|
|
1283
1351
|
---
|
|
1284
1352
|
|
|
@@ -1309,9 +1377,12 @@ m.constraint(expr`2 * ${x} + 3 * ${y} <= 100`, "resource_limit");
|
|
|
1309
1377
|
|
|
1310
1378
|
Calling `m.objective()` with no arguments returns the current objective expression (`LinExpr | QuadExpr`). If you omit setting an objective, the model defaults to minimizing `0`.
|
|
1311
1379
|
|
|
1380
|
+
> [!TIP]
|
|
1381
|
+
> For objective formulation patterns—such as **Min-Max / Max-Min load balancing**, **weighted-sum vs. lexicographic multi-objective optimization**, and **MIP stopping tolerances (`relGap` / `absGap`)**—see [Formulation Patterns & Optimization Tips](docs/guide/formulation-tips.md#_4-multi-objective-mip-stopping-design).
|
|
1382
|
+
|
|
1312
1383
|
---
|
|
1313
1384
|
|
|
1314
|
-
##
|
|
1385
|
+
## Solving the Model (`m.solve()` vs. `m.check()`)
|
|
1315
1386
|
|
|
1316
1387
|
WaSHI provides two asynchronous execution entrypoints:
|
|
1317
1388
|
|
|
@@ -1347,7 +1418,7 @@ You can also pass a solver name directly as the first argument: `await m.solve("
|
|
|
1347
1418
|
|
|
1348
1419
|
---
|
|
1349
1420
|
|
|
1350
|
-
##
|
|
1421
|
+
## Real-Time Progress (`onProgress`) & Cancellation (`AbortSignal`)
|
|
1351
1422
|
|
|
1352
1423
|
For long-running MIP optimizations in interactive UIs, you can stream live branch-and-bound progress (`SolveProgressEvent`) and allow users to cancel the solve cleanly with `AbortController`:
|
|
1353
1424
|
|
|
@@ -1393,7 +1464,7 @@ console.log("Has incumbent:", m.hasPrimalSolution); // true if an incumbent was
|
|
|
1393
1464
|
|
|
1394
1465
|
---
|
|
1395
1466
|
|
|
1396
|
-
##
|
|
1467
|
+
## Reading Results, Bounds & Solver Status
|
|
1397
1468
|
|
|
1398
1469
|
After solving, query the status, optimal values, and MIP optimality bounds on the `Model`:
|
|
1399
1470
|
|
|
@@ -1441,7 +1512,7 @@ If an integer-feasible incumbent was found before hitting `timeLimit` or `signal
|
|
|
1441
1512
|
|
|
1442
1513
|
---
|
|
1443
1514
|
|
|
1444
|
-
##
|
|
1515
|
+
## Model Mutations & Execution Validity
|
|
1445
1516
|
|
|
1446
1517
|
A `Model` instance keeps its solution state synchronized across mutations made through `Model` methods and `Model` / `Var` / `Constraint` property setters:
|
|
1447
1518
|
|
|
@@ -1461,13 +1532,13 @@ When building real-world optimization and configuration systems, two verificatio
|
|
|
1461
1532
|
1. **Why is my model infeasible?** Which specific business rules contradict each other?
|
|
1462
1533
|
2. **Does the solver's solution genuinely satisfy every constraint?** How do I verify tolerances, integrality, and formulations?
|
|
1463
1534
|
|
|
1464
|
-
WaSHI provides built-in tools for both: **
|
|
1535
|
+
WaSHI provides built-in tools for both: **conflict diagnosis** via built-in HiGHS elastic relaxation or Z3 Unsat Core (`m.check()`), **independent solution validation** (`m.validateSolution()`), **LP formulation export** (`m.toLpString()`), and **fast unit testing** (`MockSolver`).
|
|
1465
1536
|
|
|
1466
1537
|
---
|
|
1467
1538
|
|
|
1468
|
-
##
|
|
1539
|
+
## Conflict Diagnosis (`unsatCore` / `conflicts`)
|
|
1469
1540
|
|
|
1470
|
-
When
|
|
1541
|
+
When an optimization run (`m.solve()`) returns **`"Infeasible"`**, the status alone tells you that no solution satisfies every constraint—not *which* rules conflict with each other.
|
|
1471
1542
|
By calling **`await m.check({ solver: "z3" })`**, WaSHI uses Z3's `assertAndTrack` mechanism to compute an **Unsatisfiable Core (`unsatCore`)**—a conflicting subset of named constraints that cannot be satisfied simultaneously:
|
|
1472
1543
|
|
|
1473
1544
|
```typescript
|
|
@@ -1519,9 +1590,21 @@ if (status === "Infeasible") {
|
|
|
1519
1590
|
}
|
|
1520
1591
|
```
|
|
1521
1592
|
|
|
1593
|
+
### Comparing Z3 `unsatCore` vs. HiGHS Elastic Slack `conflicts`
|
|
1594
|
+
|
|
1595
|
+
| Property | Z3 (`m.check({ solver: "z3" })`) | HiGHS / GLPK (`m.check()`) |
|
|
1596
|
+
| :--- | :--- | :--- |
|
|
1597
|
+
| **Algorithm** | SMT assumption tracking (`assertAndTrack`) | Single-solve $L_1$ elastic slack relaxation ($\min \sum s_i$) |
|
|
1598
|
+
| **Populated Fields** | Both `result.unsatCore` and `result.conflicts` | `result.conflicts` (`unsatCore` is `undefined`) |
|
|
1599
|
+
| **Semantic Meaning** | A **contradictory subset** of rules that cannot all hold together | A **minimum-violation repair set** of constraints whose relaxation restores feasibility |
|
|
1600
|
+
| **Unit-Scaling Sensitivity** | **Scale-invariant** (pure logical/algebraic contradiction) | **Scale-sensitive** (minimizing unweighted $\sum s_i$ prefers relaxing a headcount constraint by `2` units over a budget constraint by `10,000` dollars) |
|
|
1601
|
+
|
|
1602
|
+
> [!TIP]
|
|
1603
|
+
> When diagnosing models that mix constraints of vastly different magnitudes (e.g., headcount $\le 8$ alongside budget $\le 50{,}000$), use **`m.check({ solver: "z3" })`** if you want the complete scale-invariant set of mutually contradictory rules, or normalize constraint scales when relying on unweighted $L_1$ slack relaxation.
|
|
1604
|
+
|
|
1522
1605
|
---
|
|
1523
1606
|
|
|
1524
|
-
##
|
|
1607
|
+
## Independent Solution Validation (`m.validateSolution()`)
|
|
1525
1608
|
|
|
1526
1609
|
After solving a model (or supplying a custom candidate map via `values`), you can independently verify that all variable bounds, integer/binary domains, linear constraints, and logical SMT formulas hold against the original model definitions:
|
|
1527
1610
|
|
|
@@ -1561,7 +1644,7 @@ if (m.hasPrimalSolution) {
|
|
|
1561
1644
|
|
|
1562
1645
|
---
|
|
1563
1646
|
|
|
1564
|
-
##
|
|
1647
|
+
## Inspecting the LP Formulation (`m.toLpString()`)
|
|
1565
1648
|
|
|
1566
1649
|
When debugging coefficients, signs, or Big-M indicator expansions, call `m.toLpString()` to export the model in standard CPLEX/HiGHS `.lp` format:
|
|
1567
1650
|
|
|
@@ -1587,7 +1670,7 @@ End
|
|
|
1587
1670
|
|
|
1588
1671
|
---
|
|
1589
1672
|
|
|
1590
|
-
##
|
|
1673
|
+
## Unit Testing Application Logic with `MockSolver`
|
|
1591
1674
|
|
|
1592
1675
|
When running unit tests for application code that builds or consumes a WaSHI `Model`, you can use the built-in `MockSolver` (`solver: "mock"`) to return deterministic results instantaneously without loading WebAssembly:
|
|
1593
1676
|
|
|
@@ -1612,165 +1695,166 @@ console.log(x.value, y.value); // 10, 20
|
|
|
1612
1695
|
|
|
1613
1696
|
---
|
|
1614
1697
|
|
|
1615
|
-
#
|
|
1698
|
+
# Decomposition Methods (`solveColumnGeneration` & `solveBenders`)
|
|
1616
1699
|
|
|
1617
|
-
Mixed-Integer Programming (MIP)
|
|
1618
|
-
WaSHI provides decomposition orchestrators (`master.solveColumnGeneration` and `master.solveBenders`) along with lower-level modeling primitives for dual inspection and dynamic model modification.
|
|
1700
|
+
When monolithic Mixed-Integer Programming (MIP) models have exponentially many candidate patterns or large multi-stage subproblems, WaSHI provides two high-level decomposition orchestrators—`master.solveColumnGeneration()` and `master.solveBenders()`—along with low-level primitives for dual inspection and dynamic model updates.
|
|
1619
1701
|
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
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) |
|
|
1702
|
+
| Decomposition Method | WaSHI Orchestrator | Typical Use Case |
|
|
1703
|
+
| :--- | :--- | :--- |
|
|
1704
|
+
| **Column Generation** *(Dantzig-Wolfe)* | [`master.solveColumnGeneration()`](#column-generation-solvecolumngeneration) | Models with exponentially many patterns, routes, or schedules (e.g., Cutting Stock, Vehicle Routing, Crew Rostering). |
|
|
1705
|
+
| **Benders Decomposition** *(and Lazy Cuts)* | [`master.solveBenders()`](#benders-decomposition-solvebenders) | Two-stage or complicating-variable problems (e.g., Facility Location + Dispatch, Stochastic Planning, TSP Subtour Elimination). |
|
|
1630
1706
|
|
|
1631
1707
|
---
|
|
1632
1708
|
|
|
1633
1709
|
## Foundational Primitives
|
|
1634
1710
|
|
|
1635
|
-
WaSHI
|
|
1711
|
+
WaSHI exposes four lower-level primitives used internally by the decomposition orchestrators and available directly for custom algorithms:
|
|
1636
1712
|
|
|
1637
|
-
### 1. Constraint Duals (`dual`)
|
|
1638
|
-
|
|
1713
|
+
### 1. Constraint Duals (`c.dual`)
|
|
1714
|
+
After solving a continuous linear program (LP), read the shadow price (marginal impact on the objective per unit change in RHS) of each constraint:
|
|
1639
1715
|
|
|
1640
1716
|
```typescript
|
|
1641
|
-
|
|
1717
|
+
import { Model, expr, maximize } from "@ohtaman/washi";
|
|
1718
|
+
|
|
1719
|
+
const m = new Model();
|
|
1720
|
+
const x = m.continuous("x", { lb: 0 });
|
|
1721
|
+
const y = m.continuous("y", { lb: 0 });
|
|
1722
|
+
|
|
1723
|
+
m.objective(maximize(expr`3 * ${x} + 2 * ${y}`));
|
|
1724
|
+
const c = m.constraint(expr`${x} + ${y} <= 10`, "capacity");
|
|
1642
1725
|
await m.solve();
|
|
1643
1726
|
|
|
1644
|
-
|
|
1645
|
-
console.log(
|
|
1646
|
-
console.log(m.solution?.duals.capacity);
|
|
1727
|
+
console.log(c.dual); // Dual value / shadow price
|
|
1728
|
+
console.log(m.solution?.duals.capacity); // Lookup by constraint name
|
|
1647
1729
|
```
|
|
1648
1730
|
|
|
1649
|
-
### 2. Continuous LP Relaxation (`
|
|
1650
|
-
|
|
1731
|
+
### 2. Continuous LP Relaxation (`m.relax()`)
|
|
1732
|
+
`m.relax()` temporarily relaxes all `Integer` and `Binary` variables to continuous variables within their `[lb, ub]` bounds and solves the LP relaxation so constraint duals are populated:
|
|
1651
1733
|
|
|
1652
1734
|
```typescript
|
|
1653
|
-
// Solves LP relaxation and populates
|
|
1735
|
+
// Solves the continuous LP relaxation and populates m.solution.duals
|
|
1654
1736
|
const status = await m.relax();
|
|
1655
|
-
console.log("
|
|
1737
|
+
console.log("LP relaxation bound:", m.objectiveValue);
|
|
1656
1738
|
```
|
|
1657
1739
|
|
|
1658
|
-
### 3. Dynamic Column Injection (`
|
|
1659
|
-
|
|
1740
|
+
### 3. Dynamic Column Injection (`m.addColumn()`)
|
|
1741
|
+
Create a new decision variable, set its objective coefficient, and attach its non-zero coefficients to existing named constraints in a single call:
|
|
1660
1742
|
|
|
1661
1743
|
```typescript
|
|
1662
1744
|
const col = m.addColumn({
|
|
1663
1745
|
name: "pattern_1",
|
|
1664
|
-
type: "Integer", // or "Continuous"
|
|
1746
|
+
type: "Integer", // or "Continuous" / "Binary"
|
|
1665
1747
|
lb: 0,
|
|
1666
|
-
cost: 1, //
|
|
1748
|
+
cost: 1, // Objective coefficient
|
|
1667
1749
|
coefficients: {
|
|
1668
|
-
|
|
1669
|
-
demand_itemB: 1,
|
|
1750
|
+
capacity: 2,
|
|
1670
1751
|
},
|
|
1671
1752
|
});
|
|
1672
1753
|
```
|
|
1673
1754
|
|
|
1674
|
-
### 4. Dynamic Constraint
|
|
1675
|
-
|
|
1755
|
+
### 4. Dynamic Constraint Term Updates (`c.addTerm()` / `c.setTerm()`)
|
|
1756
|
+
Increment or overwrite the coefficient of a variable inside an existing linear constraint:
|
|
1676
1757
|
|
|
1677
1758
|
```typescript
|
|
1678
|
-
const
|
|
1679
|
-
|
|
1680
|
-
|
|
1759
|
+
const extra = m.continuous("extra", { lb: 0 });
|
|
1760
|
+
const budget = m.constraint(expr`${x} + ${y} <= 20`, "budget");
|
|
1761
|
+
|
|
1762
|
+
budget.addTerm(extra, 5); // Adds +5 * extra to the LHS
|
|
1763
|
+
budget.setTerm(extra, 0); // Setting 0 removes extra from the constraint
|
|
1681
1764
|
```
|
|
1682
1765
|
|
|
1683
1766
|
---
|
|
1684
1767
|
|
|
1685
1768
|
## Column Generation (`solveColumnGeneration`)
|
|
1686
1769
|
|
|
1687
|
-
|
|
1688
|
-
1.
|
|
1689
|
-
2.
|
|
1690
|
-
3.
|
|
1691
|
-
4. Repeats until no
|
|
1692
|
-
5.
|
|
1770
|
+
`master.solveColumnGeneration(options)` starts from a **Restricted Master Problem (RMP)** containing an initial feasible subset of columns and executes the column generation loop:
|
|
1771
|
+
1. Solves the continuous LP relaxation of the master problem via `master.relax()`.
|
|
1772
|
+
2. Invokes your `pricing` callback with the current dual shadow prices (`duals`, `getDual`).
|
|
1773
|
+
3. Evaluates candidate columns (`ColumnProposal` or `ColumnProposal[]`), dynamically injecting those with improving reduced costs ($\bar{c} = \text{cost} - \sum_i \pi_i a_i < 0$ when minimizing).
|
|
1774
|
+
4. Repeats until `pricing` returns `null` / `[]` or no column improves the objective beyond `reducedCostTolerance`.
|
|
1775
|
+
5. Optionally solves the final master model as a MIP (`finalizeAsMIP: true`, the default) to obtain an integer solution over all generated columns.
|
|
1693
1776
|
|
|
1694
|
-
###
|
|
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:
|
|
1777
|
+
### Pricing with a WaSHI Subproblem Model
|
|
1697
1778
|
|
|
1698
1779
|
```typescript
|
|
1699
1780
|
import { Model, expr, minimize, maximize } from "@ohtaman/washi";
|
|
1700
1781
|
|
|
1701
1782
|
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
1783
|
|
|
1708
|
-
// 1.
|
|
1784
|
+
// 1. Restricted Master Problem (RMP) with simple single-item initial patterns
|
|
1709
1785
|
const master = new Model("CuttingStockMaster");
|
|
1710
|
-
|
|
1711
|
-
const
|
|
1712
|
-
const
|
|
1713
|
-
const pC = master.integer("init_C", { lb: 0 }); // 6 of C per roll (16*6 = 96 <= 100)
|
|
1786
|
+
const pA = master.integer("init_A", { lb: 0 }); // 2 of A (width 45) per roll
|
|
1787
|
+
const pB = master.integer("init_B", { lb: 0 }); // 2 of B (width 36) per roll
|
|
1788
|
+
const pC = master.integer("init_C", { lb: 0 }); // 6 of C (width 16) per roll
|
|
1714
1789
|
|
|
1715
1790
|
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;
|
|
1791
|
+
const demandA = master.constraint(expr`2 * ${pA} >= 10`, "demand_A");
|
|
1792
|
+
const demandB = master.constraint(expr`2 * ${pB} >= 14`, "demand_B");
|
|
1793
|
+
const demandC = master.constraint(expr`6 * ${pC} >= 20`, "demand_C");
|
|
1720
1794
|
|
|
1721
|
-
// 2.
|
|
1795
|
+
// 2. Run Column Generation
|
|
1722
1796
|
const result = await master.solveColumnGeneration({
|
|
1723
1797
|
finalizeAsMIP: true,
|
|
1724
1798
|
pricing: async () => {
|
|
1725
|
-
//
|
|
1799
|
+
// Knapsack pricing subproblem: find a pattern maximizing total dual value
|
|
1726
1800
|
const sub = new Model("KnapsackPricing");
|
|
1727
|
-
const
|
|
1728
|
-
const
|
|
1729
|
-
const
|
|
1801
|
+
const aA = sub.integer("a_A", { lb: 0, ub: 2 });
|
|
1802
|
+
const aB = sub.integer("a_B", { lb: 0, ub: 2 });
|
|
1803
|
+
const aC = sub.integer("a_C", { lb: 0, ub: 6 });
|
|
1730
1804
|
|
|
1731
|
-
// Maximize shadow price collection
|
|
1732
1805
|
const dualA = demandA.dual ?? 0;
|
|
1733
1806
|
const dualB = demandB.dual ?? 0;
|
|
1734
1807
|
const dualC = demandC.dual ?? 0;
|
|
1735
|
-
sub.objective(maximize(expr`${dualA} * ${
|
|
1736
|
-
sub.constraint(expr`45 * ${
|
|
1808
|
+
sub.objective(maximize(expr`${dualA} * ${aA} + ${dualB} * ${aB} + ${dualC} * ${aC}`));
|
|
1809
|
+
sub.constraint(expr`45 * ${aA} + 36 * ${aB} + 16 * ${aC} <= ${rollWidth}`);
|
|
1737
1810
|
|
|
1738
1811
|
await sub.solve();
|
|
1739
1812
|
|
|
1740
|
-
// Return the generated cutting pattern
|
|
1741
1813
|
return {
|
|
1742
1814
|
cost: 1,
|
|
1743
1815
|
type: "Integer",
|
|
1744
1816
|
coefficients: {
|
|
1745
|
-
demand_A: Math.round(
|
|
1746
|
-
demand_B: Math.round(
|
|
1747
|
-
demand_C: Math.round(
|
|
1817
|
+
demand_A: Math.round(aA.value ?? 0),
|
|
1818
|
+
demand_B: Math.round(aB.value ?? 0),
|
|
1819
|
+
demand_C: Math.round(aC.value ?? 0),
|
|
1748
1820
|
},
|
|
1749
1821
|
};
|
|
1750
1822
|
},
|
|
1751
1823
|
});
|
|
1752
1824
|
|
|
1753
|
-
console.log("Optimal rolls
|
|
1754
|
-
console.log("Iterations
|
|
1755
|
-
console.log("
|
|
1825
|
+
console.log("Optimal rolls:", result.objectiveValue); // 14
|
|
1826
|
+
console.log("Iterations:", result.iterations);
|
|
1827
|
+
console.log("Columns added:", result.columnsAdded);
|
|
1756
1828
|
```
|
|
1757
1829
|
|
|
1758
|
-
###
|
|
1830
|
+
### Pricing with a Custom Algorithm (`getDual`)
|
|
1759
1831
|
|
|
1760
|
-
Because `pricing` is a standard callback
|
|
1832
|
+
Because `pricing` is a standard callback receiving `PricingContext`, you can also solve the pricing problem directly in TypeScript (e.g., via combinatorial search, dynamic programming, or shortest-path routines) instead of constructing a subproblem `Model`:
|
|
1761
1833
|
|
|
1762
1834
|
```typescript
|
|
1763
|
-
const
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
};
|
|
1835
|
+
const customResult = await master.solveColumnGeneration({
|
|
1836
|
+
maxIterations: 100,
|
|
1837
|
+
reducedCostTolerance: 1e-6,
|
|
1838
|
+
pricing: ({ getDual }) => {
|
|
1839
|
+
const dA = getDual("demand_A");
|
|
1840
|
+
const dB = getDual("demand_B");
|
|
1841
|
+
const dC = getDual("demand_C");
|
|
1842
|
+
|
|
1843
|
+
// Search feasible patterns (45*aA + 36*aB + 16*aC <= rollWidth) for negative reduced cost (1 - dualSum < -1e-6)
|
|
1844
|
+
let bestDualSum = 1 + 1e-6;
|
|
1845
|
+
let best: { demand_A: number; demand_B: number; demand_C: number } | null = null;
|
|
1846
|
+
for (let aA = 0; 45 * aA <= rollWidth; aA++) {
|
|
1847
|
+
for (let aB = 0; 45 * aA + 36 * aB <= rollWidth; aB++) {
|
|
1848
|
+
for (let aC = 0; 45 * aA + 36 * aB + 16 * aC <= rollWidth; aC++) {
|
|
1849
|
+
const dualSum = dA * aA + dB * aB + dC * aC;
|
|
1850
|
+
if (dualSum > bestDualSum) {
|
|
1851
|
+
bestDualSum = dualSum;
|
|
1852
|
+
best = { demand_A: aA, demand_B: aB, demand_C: aC };
|
|
1853
|
+
}
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1856
|
+
}
|
|
1857
|
+
return best ? { cost: 1, type: "Integer", coefficients: best } : null;
|
|
1774
1858
|
},
|
|
1775
1859
|
});
|
|
1776
1860
|
```
|
|
@@ -1779,33 +1863,29 @@ const result = await master.solveColumnGeneration({
|
|
|
1779
1863
|
|
|
1780
1864
|
## Benders Decomposition (`solveBenders`)
|
|
1781
1865
|
|
|
1782
|
-
|
|
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.
|
|
1866
|
+
`master.solveBenders(options)` partitions a problem into a **Master Problem** (holding first-stage or complicating variables) and a **Subproblem** evaluated at each master candidate solution to generate **Optimality Cuts** or **Feasibility Cuts**.
|
|
1785
1867
|
|
|
1786
|
-
###
|
|
1868
|
+
### Automatic Cut Generation (`sub.link()`)
|
|
1787
1869
|
|
|
1788
|
-
Return
|
|
1870
|
+
Return a continuous LP subproblem `Model` from `subproblem` and declare cross-model constraints via `sub.link(...)`. WaSHI automatically manages the auxiliary cost estimator variable $\theta$, substitutes master candidate values, extracts subproblem duals, and adds Benders cuts until convergence:
|
|
1789
1871
|
|
|
1790
1872
|
```typescript
|
|
1791
1873
|
import { Model, expr, minimize } from "@ohtaman/washi";
|
|
1792
1874
|
|
|
1793
|
-
// 1. Master problem:
|
|
1875
|
+
// 1. Master problem: binary facility opening decisions
|
|
1794
1876
|
const master = new Model("FacilityMaster");
|
|
1795
1877
|
const y1 = master.binary("open_F1");
|
|
1796
1878
|
const y2 = master.binary("open_F2");
|
|
1797
1879
|
|
|
1798
|
-
//
|
|
1880
|
+
// Fixed opening costs (theta for operational subproblem cost is managed automatically)
|
|
1799
1881
|
master.objective(minimize(expr`100 * ${y1} + 120 * ${y2}`));
|
|
1800
|
-
|
|
1801
|
-
master.constraint(expr`50 * ${y1} + 60 * ${y2} >= 75`, "capacity_check");
|
|
1882
|
+
master.constraint(expr`50 * ${y1} + 60 * ${y2} >= 75`, "total_capacity_floor");
|
|
1802
1883
|
|
|
1803
|
-
// 2. Solve
|
|
1884
|
+
// 2. Solve with automatic Benders cut generation
|
|
1804
1885
|
const result = await master.solveBenders({
|
|
1805
1886
|
gapTolerance: 1e-4,
|
|
1806
1887
|
subproblem: () => {
|
|
1807
|
-
|
|
1808
|
-
const sub = new Model("TransportSubproblem");
|
|
1888
|
+
const sub = new Model("DispatchSubproblem");
|
|
1809
1889
|
const x11 = sub.continuous("x_11", { lb: 0 });
|
|
1810
1890
|
const x12 = sub.continuous("x_12", { lb: 0 });
|
|
1811
1891
|
const x13 = sub.continuous("x_13", { lb: 0 });
|
|
@@ -1819,199 +1899,285 @@ const result = await master.solveBenders({
|
|
|
1819
1899
|
)
|
|
1820
1900
|
);
|
|
1821
1901
|
|
|
1822
|
-
// Customer demand constraints
|
|
1823
1902
|
sub.constraint(expr`${x11} + ${x21} >= 20`, "demand_C1");
|
|
1824
1903
|
sub.constraint(expr`${x12} + ${x22} >= 25`, "demand_C2");
|
|
1825
1904
|
sub.constraint(expr`${x13} + ${x23} >= 30`, "demand_C3");
|
|
1826
1905
|
|
|
1827
|
-
// Link subproblem flows
|
|
1906
|
+
// Link subproblem dispatch flows to master binary variables y1, y2
|
|
1828
1907
|
sub.link(expr`${x11} + ${x12} + ${x13} <= 50 * ${y1}`);
|
|
1829
1908
|
sub.link(expr`${x21} + ${x22} + ${x23} <= 60 * ${y2}`);
|
|
1830
1909
|
|
|
1831
|
-
return sub;
|
|
1910
|
+
return sub;
|
|
1832
1911
|
},
|
|
1833
1912
|
});
|
|
1834
1913
|
|
|
1835
1914
|
console.log("Optimal total cost:", result.objectiveValue); // 390
|
|
1836
|
-
console.log("Iterations:", result.iterations);
|
|
1837
1915
|
console.log("Cuts added:", result.cutsAdded);
|
|
1838
|
-
console.log("
|
|
1839
|
-
F1: result.values.open_F1,
|
|
1840
|
-
F2: result.values.open_F2,
|
|
1841
|
-
});
|
|
1916
|
+
console.log("Open facilities:", result.values);
|
|
1842
1917
|
```
|
|
1843
1918
|
|
|
1844
|
-
|
|
1919
|
+
> [!NOTE]
|
|
1920
|
+
> **`theta` Lower Bound for Negative Subproblem Objectives**: When `sub.link(...)` is used without an explicit `theta` variable, WaSHI injects `__benders_theta` with default `lb: 0` (assuming non-negative subproblem costs). If your subproblem objective can take negative values (e.g., rebate or profit terms), declare `const theta = master.continuous("theta", { lb: -1e6 })`, include `+ ${theta}` in `master.objective(...)`, and pass `{ theta }` in `solveBenders(options)`.
|
|
1921
|
+
|
|
1922
|
+
### Manual Cut Generation (`BendersCut` / Lazy Constraints)
|
|
1845
1923
|
|
|
1846
|
-
|
|
1924
|
+
For combinatorial separation (such as lazy subtour elimination) or custom analytical cuts, return one `BendersCut` object, an array `BendersCut[]`, or `null` when converged:
|
|
1847
1925
|
|
|
1848
1926
|
```typescript
|
|
1927
|
+
import { Model, expr, minimize } from "@ohtaman/washi";
|
|
1928
|
+
|
|
1929
|
+
const master = new Model("ManualBenders");
|
|
1930
|
+
const y = master.binary("y");
|
|
1931
|
+
const theta = master.continuous("theta", { lb: 0 });
|
|
1932
|
+
master.objective(minimize(expr`40 * ${y} + ${theta}`));
|
|
1933
|
+
|
|
1849
1934
|
const result = await master.solveBenders({
|
|
1935
|
+
theta,
|
|
1850
1936
|
gapTolerance: 1e-4,
|
|
1851
|
-
subproblem:
|
|
1852
|
-
const
|
|
1853
|
-
const
|
|
1854
|
-
const estTheta = theta.value ?? 0;
|
|
1855
|
-
|
|
1856
|
-
const sub = new Model("TransportSubproblem");
|
|
1857
|
-
// ...
|
|
1858
|
-
await sub.solve();
|
|
1937
|
+
subproblem: ({ getMasterValue }) => {
|
|
1938
|
+
const yVal = getMasterValue(y);
|
|
1939
|
+
const estTheta = getMasterValue(theta);
|
|
1859
1940
|
|
|
1860
|
-
|
|
1861
|
-
|
|
1941
|
+
// Operational cost is 100 when y = 0, or 30 when y = 1
|
|
1942
|
+
const actualSubCost = 100 - 70 * yVal;
|
|
1943
|
+
if (estTheta < actualSubCost - 1e-4) {
|
|
1862
1944
|
return {
|
|
1863
|
-
kind: "optimality",
|
|
1864
|
-
constraint: expr`${theta}
|
|
1865
|
-
subproblemObjective:
|
|
1945
|
+
kind: "optimality", // or "feasibility" for lazy combinatorial cuts
|
|
1946
|
+
constraint: expr`${theta} >= 100 - 70 * ${y}`,
|
|
1947
|
+
subproblemObjective: 40 * yVal + actualSubCost,
|
|
1866
1948
|
};
|
|
1867
1949
|
}
|
|
1868
1950
|
return null; // Converged
|
|
1869
1951
|
},
|
|
1870
1952
|
});
|
|
1953
|
+
|
|
1954
|
+
console.log(result.objectiveValue); // 70 (y = 1, theta = 30)
|
|
1871
1955
|
```
|
|
1872
1956
|
|
|
1873
1957
|
---
|
|
1874
1958
|
|
|
1875
|
-
##
|
|
1959
|
+
## Related Examples & Formulation Tips
|
|
1876
1960
|
|
|
1877
|
-
|
|
1961
|
+
- **Full Decomposition Walkthroughs**:
|
|
1962
|
+
- [Vehicle Routing Problem (`solveColumnGeneration`)](docs/examples/vehicle-routing.md)
|
|
1963
|
+
- [Traveling Salesperson Problem (`solveBenders` lazy subtour elimination)](docs/examples/traveling-salesperson.md)
|
|
1964
|
+
- **Formulation Tips**: See [Formulation Patterns & Optimization Tips](docs/guide/formulation-tips.md#_5-decomposition-formulation-tips) for Set Covering (`>= 1`) vs. Set Partitioning (`== 1`), Price-and-Branch (`finalizeAsMIP`), and multi-cut separation.
|
|
1878
1965
|
|
|
1879
|
-
|
|
1880
|
-
|
|
1966
|
+
---
|
|
1967
|
+
|
|
1968
|
+
# Formulation Patterns & Optimization Tips
|
|
1881
1969
|
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1970
|
+
While the preceding Modeling Guide pages cover WaSHI's APIs (`Model`, `expr`, `m.constraint`, `m.solve`, etc.), this page collects standard mathematical optimization formulation patterns and solver tuning techniques frequently used in linear and mixed-integer programming (LP/MIP).
|
|
1971
|
+
|
|
1972
|
+
---
|
|
1973
|
+
|
|
1974
|
+
## 1. Variables & Domain Modeling
|
|
1975
|
+
|
|
1976
|
+
### Semi-Continuous Decisions ($\{0\} \cup [L, U]$)
|
|
1977
|
+
|
|
1978
|
+
A semi-continuous quantity must be either `0` (inactive) or within a positive interval `[L, U]` (e.g., a production batch with a minimum run size $L = 10$ and capacity $U = 100$).
|
|
1979
|
+
|
|
1980
|
+
Combine a continuous variable `x` with a binary activation variable `isActive` and linking constraints $L \cdot b \le x \le U \cdot b$:
|
|
1886
1981
|
|
|
1887
1982
|
```typescript
|
|
1888
|
-
import { Model, expr
|
|
1983
|
+
import { Model, expr } from "@ohtaman/washi";
|
|
1889
1984
|
|
|
1890
|
-
const
|
|
1891
|
-
const
|
|
1892
|
-
|
|
1893
|
-
{ id: "C2", demand: 3 },
|
|
1894
|
-
{ id: "C3", demand: 5 },
|
|
1895
|
-
{ id: "C4", demand: 2 },
|
|
1896
|
-
{ id: "C5", demand: 4 },
|
|
1897
|
-
];
|
|
1985
|
+
const m = new Model("SemiContinuousBatch");
|
|
1986
|
+
const batch = m.continuous("batch", { lb: 0, ub: 100 });
|
|
1987
|
+
const isActive = m.binary("is_active");
|
|
1898
1988
|
|
|
1899
|
-
// 1
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
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())));
|
|
1989
|
+
// If isActive === 0, batch is forced to 0; if isActive === 1, batch lies in [10, 100]
|
|
1990
|
+
m.constraint(expr`${batch} >= 10 * ${isActive}`, "min_batch_run");
|
|
1991
|
+
m.constraint(expr`${batch} <= 100 * ${isActive}`, "max_batch_run");
|
|
1992
|
+
```
|
|
1906
1993
|
|
|
1907
|
-
|
|
1908
|
-
master.constraint(expr`${initialRoutes[i]} >= 1`, `visit_${customers[i].id}`);
|
|
1909
|
-
}
|
|
1910
|
-
const visitConstrs = master.constraints;
|
|
1994
|
+
### Minimal-Perturbation Rescheduling vs. Warm Starting
|
|
1911
1995
|
|
|
1912
|
-
|
|
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
|
-
}
|
|
1996
|
+
When re-optimizing an existing schedule after a disruption, passing yesterday's assignment via [`initialValue`](docs/guide/variables.md#primal-initial-values-warm-start-hints) only gives the MIP solver a starting search point—it does **not** prevent the solver from changing every assignment for a tiny objective gain.
|
|
1925
1997
|
|
|
1926
|
-
|
|
1998
|
+
To keep a revised binary plan $x_i \in \{0, 1\}$ close to a baseline assignment $x_i^{\text{prev}} \in \{0, 1\}$, penalize the **Hamming distance** (number of flipped decisions) in the objective:
|
|
1927
1999
|
|
|
1928
|
-
|
|
1929
|
-
const load = customers.reduce((acc, c) => acc.add(a[c.id].mul(c.demand)), new LinExpr());
|
|
1930
|
-
sub.constraint(expr`${load} <= ${vehicleCapacity}`);
|
|
2000
|
+
$$\text{Changes} = \sum_{i:\, x_i^{\text{prev}} = 0} x_i + \sum_{i:\, x_i^{\text{prev}} = 1} (1 - x_i)$$
|
|
1931
2001
|
|
|
1932
|
-
|
|
2002
|
+
```typescript
|
|
2003
|
+
import { Model, expr, minimize } from "@ohtaman/washi";
|
|
1933
2004
|
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
2005
|
+
const m = new Model("StableReschedule");
|
|
2006
|
+
const prevPlan = [1, 0, 1, 0];
|
|
2007
|
+
const x = prevPlan.map((prev, i) =>
|
|
2008
|
+
m.binary(`slot_${i}`, { initialValue: prev })
|
|
2009
|
+
);
|
|
1938
2010
|
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
type: "Integer",
|
|
1942
|
-
coefficients,
|
|
1943
|
-
};
|
|
1944
|
-
},
|
|
1945
|
-
});
|
|
2011
|
+
// Linear terms counting how many 0/1 decisions differ from prevPlan
|
|
2012
|
+
const flipTerms = x.map((v, i) => (prevPlan[i] === 1 ? expr`1 - ${v}` : v));
|
|
1946
2013
|
|
|
1947
|
-
|
|
2014
|
+
// Require at least 3 active slots while penalizing changes from prevPlan
|
|
2015
|
+
m.constraint(expr`sum(${x}) >= 3`, "min_active_slots");
|
|
2016
|
+
m.objective(minimize(expr`5 * sum(${flipTerms})`));
|
|
1948
2017
|
```
|
|
1949
2018
|
|
|
1950
|
-
|
|
1951
|
-
|
|
2019
|
+
---
|
|
2020
|
+
|
|
2021
|
+
## 2. Linearizing Non-Linear Expressions
|
|
1952
2022
|
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
2023
|
+
### Product of Two Binary Variables ($x_A \cdot x_B$)
|
|
2024
|
+
|
|
2025
|
+
In integer programming, if an interaction cost or synergy bonus applies only when two binary decisions $x_A, x_B \in \{0, 1\}$ are **both** equal to `1`, avoid introducing a quadratic term. Instead, introduce an auxiliary variable $z_{AB} \in [0, 1]$ and add the three standard McCormick / Fortet linear inequalities so the model remains a pure MILP:
|
|
2026
|
+
|
|
2027
|
+
$$z_{AB} \le x_A, \quad z_{AB} \le x_B, \quad z_{AB} \ge x_A + x_B - 1$$
|
|
1957
2028
|
|
|
1958
2029
|
```typescript
|
|
1959
|
-
import { Model, expr,
|
|
2030
|
+
import { Model, expr, maximize } from "@ohtaman/washi";
|
|
1960
2031
|
|
|
1961
|
-
const
|
|
1962
|
-
const
|
|
1963
|
-
const
|
|
1964
|
-
|
|
2032
|
+
const m = new Model("BinaryProductLinearization");
|
|
2033
|
+
const xA = m.binary("xA");
|
|
2034
|
+
const xB = m.binary("xB");
|
|
2035
|
+
// When xA and xB are binary, continuous [0, 1] bounds suffice to force bothAB into {0, 1}
|
|
2036
|
+
const bothAB = m.continuous("both_AB", { lb: 0, ub: 1 });
|
|
1965
2037
|
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
2038
|
+
m.constraint(expr`${bothAB} <= ${xA}`, "and_upper_A");
|
|
2039
|
+
m.constraint(expr`${bothAB} <= ${xB}`, "and_upper_B");
|
|
2040
|
+
m.constraint(expr`${bothAB} >= ${xA} + ${xB} - 1`, "and_lower_AB");
|
|
2041
|
+
|
|
2042
|
+
m.objective(maximize(expr`10 * ${xA} + 10 * ${xB} + 5 * ${bothAB}`));
|
|
2043
|
+
```
|
|
2044
|
+
|
|
2045
|
+
### Min-Max & Max-Min Objectives (Load Balancing)
|
|
2046
|
+
|
|
2047
|
+
Linear and quadratic objectives cannot call `Math.max(...)` directly. To minimize a bottleneck or peak workload $\min \max_i (\text{load}_i)$, introduce an auxiliary ceiling variable $z$, constrain every individual load by $\text{load}_i \le z$, and minimize $z$:
|
|
2048
|
+
|
|
2049
|
+
$$\min z \quad \text{subject to} \quad \text{load}_i \le z \quad \forall i$$
|
|
2050
|
+
|
|
2051
|
+
```typescript
|
|
2052
|
+
import { Model, expr, minimize } from "@ohtaman/washi";
|
|
2053
|
+
|
|
2054
|
+
const m = new Model("MinMaxLoadBalancing");
|
|
2055
|
+
const workers = ["Alice", "Bob", "Carol"] as const;
|
|
2056
|
+
const hours = m.continuous(workers, { lb: 0, ub: 40 });
|
|
2057
|
+
const maxHours = m.continuous("max_hours", { lb: 0, ub: 40 });
|
|
2058
|
+
|
|
2059
|
+
// Bound every worker's hours by the shared ceiling maxHours
|
|
2060
|
+
m.constraint(workers.map((w) => expr`${hours[w]} <= ${maxHours}`));
|
|
2061
|
+
m.constraint(expr`sum(${hours}) >= 60`, "total_required_work");
|
|
2062
|
+
|
|
2063
|
+
// Minimizing maxHours distributes the 60 hours evenly (20 hours each)
|
|
2064
|
+
m.objective(minimize(maxHours));
|
|
2065
|
+
```
|
|
2066
|
+
|
|
2067
|
+
*(Symmetrically, to **maximize the minimum** level $\max \min_i u_i$, impose $u_i \ge z$ for all $i$ and set `m.objective(maximize(z))`.)*
|
|
2068
|
+
|
|
2069
|
+
---
|
|
2070
|
+
|
|
2071
|
+
## 3. Constraint Design & Numerical Conditioning
|
|
2072
|
+
|
|
2073
|
+
### Hard vs. Soft Constraints (Elastic Slack Variables)
|
|
2074
|
+
|
|
2075
|
+
Treating every business target as a strict **hard constraint** makes a model `"Infeasible"` as soon as demand exceeds capacity. Keep physical and legal invariants as hard constraints, and model non-rigid targets as **soft constraints** by adding non-negative slack variables ($s \ge 0$) with penalty weights $W$ in the objective:
|
|
2076
|
+
|
|
2077
|
+
| Target Type | Hard Constraint | Soft Constraint ($s \ge 0$) | Objective Penalty Term (Minimize) |
|
|
2078
|
+
| :--- | :--- | :--- | :--- |
|
|
2079
|
+
| **Lower bound** | $\text{lhs} \ge b$ | $\text{lhs} + s_{\text{short}} \ge b$ | $+ W \cdot s_{\text{short}}$ |
|
|
2080
|
+
| **Upper bound** | $\text{lhs} \le b$ | $\text{lhs} - s_{\text{excess}} \le b$ | $+ W \cdot s_{\text{excess}}$ |
|
|
2081
|
+
| **Target equality** | $\text{lhs} = b$ | $\text{lhs} + s_{\text{short}} - s_{\text{excess}} = b$ | $+ W_1 \cdot s_{\text{short}} + W_2 \cdot s_{\text{excess}}$ |
|
|
2082
|
+
|
|
2083
|
+
```typescript
|
|
2084
|
+
import { Model, expr, minimize } from "@ohtaman/washi";
|
|
2085
|
+
|
|
2086
|
+
const m = new Model("SoftConstraintCoverage");
|
|
2087
|
+
const assignedStaff = m.integer("assigned_staff", { lb: 0, ub: 4 }); // Max 4 staff available
|
|
2088
|
+
const shortage = m.continuous("shortage", { lb: 0 }); // Non-negative slack (s >= 0)
|
|
2089
|
+
|
|
2090
|
+
// Soft target: aim for 5 staff, allowing shortage > 0 if unavailable
|
|
2091
|
+
m.constraint(expr`${assignedStaff} + ${shortage} >= 5`, "target_5_staff");
|
|
2092
|
+
|
|
2093
|
+
// Penalize unmet shortage ($1,000) above regular wage ($100)
|
|
2094
|
+
m.objective(minimize(expr`100 * ${assignedStaff} + 1000 * ${shortage}`));
|
|
2095
|
+
|
|
2096
|
+
await m.solve();
|
|
2097
|
+
// assignedStaff.value === 4, shortage.value === 1 (returns best compromise instead of Infeasible)
|
|
2098
|
+
```
|
|
2099
|
+
|
|
2100
|
+
> [!TIP]
|
|
2101
|
+
> **Scaling Penalty Weights ($W$)**: Choose $W$ larger than normal operational marginal costs (typically $10\times$ to $1{,}000\times$ unit cost), and avoid extreme constants such as $10^{12}$. Keeping the ratio between the largest and smallest coefficients within $10^5\text{–}10^6$ preserves floating-point numerical stability in LP/MIP solvers.
|
|
2102
|
+
|
|
2103
|
+
### Symmetry Breaking for Identical Resources
|
|
2104
|
+
|
|
2105
|
+
When a model contains $K$ interchangeable resources—such as identical trucks, bins, or parallel machines—any feasible assignment has up to $K!$ equivalent permutations with the exact same objective value. Without symmetry breaking, a MIP solver explores redundant mirror-image subtrees before proving optimality.
|
|
2106
|
+
|
|
2107
|
+
Impose a canonical ordering ($u_0 \ge u_1 \ge \dots \ge u_{K-1}$) so identical resources are activated in index order:
|
|
2108
|
+
|
|
2109
|
+
```typescript
|
|
2110
|
+
import { Model, expr } from "@ohtaman/washi";
|
|
2111
|
+
|
|
2112
|
+
const m = new Model("SymmetryBreaking");
|
|
2113
|
+
const numTrucks = 4;
|
|
2114
|
+
const truckUsed = m.binary(numTrucks, { prefix: "truck_used" });
|
|
2115
|
+
|
|
2116
|
+
// Only allow activating truck k+1 when truck k is also active
|
|
2117
|
+
for (let k = 0; k < numTrucks - 1; k++) {
|
|
2118
|
+
m.constraint(
|
|
2119
|
+
expr`${truckUsed[k]} >= ${truckUsed[k + 1]}`,
|
|
2120
|
+
`order_truck_${k}`
|
|
2121
|
+
);
|
|
1989
2122
|
}
|
|
2123
|
+
```
|
|
1990
2124
|
|
|
1991
|
-
|
|
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
|
-
}
|
|
2125
|
+
---
|
|
1998
2126
|
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2127
|
+
## 4. Multi-Objective & MIP Stopping Design
|
|
2128
|
+
|
|
2129
|
+
### Weighted Sum vs. Lexicographic Two-Stage Optimization
|
|
2130
|
+
|
|
2131
|
+
When balancing competing goals—such as minimizing total cost ($f_1$) while also minimizing peak workload ($f_2$)—two approaches are standard:
|
|
2132
|
+
|
|
2133
|
+
- **Weighted Sum (Single Solve)**: Combine normalized terms into one objective, e.g., ``minimize(expr`${totalCost} + 50 * ${maxHours}`)``.
|
|
2134
|
+
- **Lexicographic (Hierarchical) Two-Stage Solve**: When the primary objective has strict priority, solve for $f_1$ first, add a constraint locking $f_1$ within an acceptable tolerance of its optimum $f_1^*$, and re-solve for $f_2$:
|
|
2135
|
+
|
|
2136
|
+
```typescript
|
|
2137
|
+
import { Model, expr, minimize } from "@ohtaman/washi";
|
|
2138
|
+
|
|
2139
|
+
const m = new Model("LexicographicTwoStage");
|
|
2140
|
+
const workers = ["Alice", "Bob"] as const;
|
|
2141
|
+
const wage = { Alice: 20, Bob: 20 };
|
|
2142
|
+
const hours = m.continuous(workers, { lb: 0, ub: 40 });
|
|
2143
|
+
const maxHours = m.continuous("max_hours", { lb: 0, ub: 40 });
|
|
2144
|
+
|
|
2145
|
+
const totalCost = expr`dot(${wage}, ${hours})`;
|
|
2146
|
+
m.constraint(expr`sum(${hours}) >= 40`, "required_hours");
|
|
2147
|
+
m.constraint(workers.map((w) => expr`${hours[w]} <= ${maxHours}`));
|
|
2148
|
+
|
|
2149
|
+
// Stage 1: Minimize primary objective (total wage cost)
|
|
2150
|
+
m.objective(minimize(totalCost));
|
|
2151
|
+
await m.solve();
|
|
2152
|
+
const minCost = m.objectiveValue!;
|
|
2153
|
+
|
|
2154
|
+
// Stage 2: Keep cost within 1% of minCost and balance workload (minimize maxHours)
|
|
2155
|
+
m.constraint(expr`${totalCost} <= ${minCost * 1.01}`, "near_optimal_cost");
|
|
2156
|
+
m.objective(minimize(maxHours));
|
|
2157
|
+
await m.solve();
|
|
2013
2158
|
```
|
|
2014
2159
|
|
|
2160
|
+
### MIP Tail-Off & Optimality Gap Tolerances
|
|
2161
|
+
|
|
2162
|
+
In combinatorial MIP problems, solvers often find a near-optimal feasible solution within seconds, then spend most of the remaining runtime closing the final fraction of a percent in the branch-and-bound tree (**tail-off**).
|
|
2163
|
+
|
|
2164
|
+
In interactive UIs and batch pipelines, combine `timeLimit` with business-meaningful tolerances in [`m.solve(options)`](docs/guide/objectives.md#solve-options-solveoptions):
|
|
2165
|
+
- **`relGap: 0.01`**: Stop immediately once the incumbent is proven within **1%** of the theoretical dual bound.
|
|
2166
|
+
- **`absGap: 100`**: Stop immediately once `|objectiveValue - objectiveBound| <= 100` in the objective's native units.
|
|
2167
|
+
|
|
2168
|
+
---
|
|
2169
|
+
|
|
2170
|
+
## 5. Decomposition Formulation Tips
|
|
2171
|
+
|
|
2172
|
+
When using [`solveColumnGeneration` or `solveBenders`](docs/guide/decomposition.md):
|
|
2173
|
+
|
|
2174
|
+
1. **Set Covering (`>= 1`) vs. Set Partitioning (`== 1`) in Column Generation**:
|
|
2175
|
+
Formulating coverage constraints as inequalities (`>= 1`) rather than equalities (`== 1`) restricts dual shadow prices to $\pi_i \ge 0$, reducing dual oscillations and accelerating convergence whenever over-coverage can be pruned at no extra cost (e.g., Euclidean routing under the triangle inequality).
|
|
2176
|
+
2. **Price-and-Branch (`finalizeAsMIP: true`) vs. Exact Branch-and-Price**:
|
|
2177
|
+
Solving the final Restricted Master Problem as a MIP over columns generated at the root LP relaxation (`finalizeAsMIP: true`) implements the **Price-and-Branch** heuristic—avoiding complex branching inside the pricing subproblem while producing high-quality integer solutions in practice.
|
|
2178
|
+
3. **Multi-Cut Separation in Benders / Lazy Constraints**:
|
|
2179
|
+
When a separation callback detects multiple disconnected subtours or violated components in a candidate solution, returning cuts for **all** violated components in one batch reduces the number of master MIP re-solves (see [Traveling Salesperson (`examples/traveling-salesperson`)](docs/examples/traveling-salesperson.md)).
|
|
2180
|
+
|
|
2015
2181
|
---
|
|
2016
2182
|
|
|
2017
2183
|
# Solvers Overview
|
|
@@ -2052,7 +2218,7 @@ const m3 = new Model({ solver: "glpk" });
|
|
|
2052
2218
|
|
|
2053
2219
|
## Solver Guides
|
|
2054
2220
|
|
|
2055
|
-
- [HiGHS Guide](docs/solvers/highs.md): Web Worker
|
|
2221
|
+
- [HiGHS Guide](docs/solvers/highs.md): Background Web Worker execution, MIP tolerances, and Convex QP solving.
|
|
2056
2222
|
- [Z3 Guide](docs/solvers/z3.md): SMT assertions, logic modeling, and Unsat Core analysis.
|
|
2057
2223
|
- [GLPK Guide](docs/solvers/glpk.md): GLPK adapter, GPL-3.0 licensing considerations, and browser usage.
|
|
2058
2224
|
- [Custom Solvers](docs/solvers/custom-solver.md): Step-by-step developer tutorial for integrating proprietary or cloud REST solvers.
|
|
@@ -2061,7 +2227,7 @@ const m3 = new Model({ solver: "glpk" });
|
|
|
2061
2227
|
|
|
2062
2228
|
# HiGHS Solver Engine
|
|
2063
2229
|
|
|
2064
|
-
**HiGHS** is high-performance mathematical optimization software
|
|
2230
|
+
**HiGHS** is high-performance mathematical optimization software initiated at the University of Edinburgh ([highs.dev](https://highs.dev/) · [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
2231
|
|
|
2066
2232
|
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.
|
|
2067
2233
|
|
|
@@ -2071,7 +2237,7 @@ In `@ohtaman/washi`, HiGHS runs entirely in WebAssembly compiled directly from o
|
|
|
2071
2237
|
|
|
2072
2238
|
- **Built-in & Zero-Config**: Bundled directly with `@ohtaman/washi` with zero external runtime dependencies. No separate peer dependency or C++ compiler required.
|
|
2073
2239
|
- **Permissive MIT License**: 100% commercially friendly.
|
|
2074
|
-
- **Web Worker
|
|
2240
|
+
- **Background Web Worker Execution**: Automatically offloads solving to a dedicated Web Worker in browser environments to avoid freezing the UI thread.
|
|
2075
2241
|
- **Warm-Start Support (`initialValue`)**: Supports passing variable primal initial value hints (`initialValue`) to HiGHS `setSolution` for warm starting.
|
|
2076
2242
|
- **Fast WASM Execution**: Runs the directly compiled 32-bit WebAssembly binary at near-native speed.
|
|
2077
2243
|
|
|
@@ -2085,14 +2251,14 @@ WaSHI compiles official HiGHS C++ source directly to WebAssembly (`highs.wasm`)
|
|
|
2085
2251
|
|
|
2086
2252
|
- **Progress Monitoring & Cancellation (Supported)**:
|
|
2087
2253
|
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
|
|
2089
|
-
|
|
2254
|
+
- **Dynamic Cuts / Lazy Constraints (Not Exposed in WASM Driver)**:
|
|
2255
|
+
WaSHI's HiGHS WebAssembly C API driver currently binds logging, progress, interrupt, and incumbent callbacks and does not expose in-tree lazy constraint or user cut callbacks across the WASM-to-JS boundary. For problems requiring dynamic subtour elimination or Benders decomposition, use [`await m.solveBenders({ subproblem })`](docs/guide/decomposition.md#benders-decomposition-solvebenders) (or a manual outer cutting-plane loop: solve $\to$ detect violation $\to$ `model.constraint(...)` $\to$ re-solve), which iteratively separates cuts and converges to the same exact optimal solution.
|
|
2090
2256
|
|
|
2091
2257
|
### WebAssembly Asset Lifecycle & On-Demand Builds
|
|
2092
2258
|
|
|
2093
2259
|
HiGHS's WebAssembly binary (`public/highs.wasm`) and runtime driver (`runtime.js`) are managed via `highs.config.json` with cryptographic SHA-256 verification:
|
|
2094
2260
|
- **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
|
|
2261
|
+
- **On-Demand Release Download**: When assets are missing or updated, `ensure-highs-wasm.mjs` automatically downloads verified binaries from WaSHI's GitHub Release assets.
|
|
2096
2262
|
- **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`).
|
|
2097
2263
|
|
|
2098
2264
|
---
|
|
@@ -2121,7 +2287,7 @@ const count = model.integer("count", { lb: 0, ub: 50 });
|
|
|
2121
2287
|
```
|
|
2122
2288
|
|
|
2123
2289
|
### 3. Convex Quadratic Programming (QP)
|
|
2124
|
-
Solves quadratic objective functions subject to linear constraints:
|
|
2290
|
+
Solves continuous models with convex quadratic objective functions subject to linear constraints:
|
|
2125
2291
|
|
|
2126
2292
|
```typescript
|
|
2127
2293
|
// Minimize risk/variance uniformly using expr:
|
|
@@ -2132,6 +2298,9 @@ model.objective(minimize(expr`0.5 * ${x}^2 + 0.5 * ${y}^2`));
|
|
|
2132
2298
|
// model.objective(minimize(quadObj));
|
|
2133
2299
|
```
|
|
2134
2300
|
|
|
2301
|
+
> [!NOTE]
|
|
2302
|
+
> **Continuous Convex QP Only**: HiGHS solves Convex QP models where **all** model variables are continuous (`m.continuous`), with a positive semi-definite quadratic matrix $\mathbf{Q}$ when minimizing (or negative semi-definite when maximizing). Mixed-Integer Quadratic Programming (**MIQP**—any model combining a quadratic objective with `Integer`, `Binary`, or `BoolVar` variables) and quadratic constraints (**QCP**) are not supported; to model products of binary decisions in MILP, see [Formulation Patterns & Tips](docs/guide/formulation-tips.md).
|
|
2303
|
+
|
|
2135
2304
|
---
|
|
2136
2305
|
|
|
2137
2306
|
## Solver Options
|
|
@@ -2152,6 +2321,14 @@ await model.solve({
|
|
|
2152
2321
|
console.log(`Elapsed: ${ev.elapsedTime.toFixed(1)}s, Best: ${ev.objectiveValue}`);
|
|
2153
2322
|
},
|
|
2154
2323
|
});
|
|
2324
|
+
|
|
2325
|
+
// Always check hasPrimalSolution when timeLimit or AbortSignal is used:
|
|
2326
|
+
if (model.hasPrimalSolution) {
|
|
2327
|
+
console.log(
|
|
2328
|
+
`Usable solution (${model.status}, termination: ${model.terminationStatus}):`,
|
|
2329
|
+
`obj = ${model.objectiveValue}, gap = ${((model.relGap ?? 0) * 100).toFixed(2)}%`
|
|
2330
|
+
);
|
|
2331
|
+
}
|
|
2155
2332
|
```
|
|
2156
2333
|
|
|
2157
2334
|
---
|
|
@@ -2176,6 +2353,16 @@ const syncModel = new Model({
|
|
|
2176
2353
|
|
|
2177
2354
|
---
|
|
2178
2355
|
|
|
2356
|
+
## References & Upstream Links
|
|
2357
|
+
|
|
2358
|
+
- **Official Website**: [https://highs.dev/](https://highs.dev/)
|
|
2359
|
+
- **Official Documentation**: [https://ergo-code.github.io/HiGHS/](https://ergo-code.github.io/HiGHS/)
|
|
2360
|
+
- **Source Repository**: [ERGO-Code/HiGHS](https://github.com/ERGO-Code/HiGHS) (MIT License)
|
|
2361
|
+
- **Academic Citation**: If you cite HiGHS in academic or technical publications, please reference the foundational paper requested by the HiGHS authors:
|
|
2362
|
+
> Q. Huangfu and J. A. J. Hall, *"Parallelizing the dual revised simplex method"*, Mathematical Programming Computation, 10 (1), 119–142, 2018. DOI: [10.1007/s12532-017-0130-5](https://doi.org/10.1007/s12532-017-0130-5)
|
|
2363
|
+
|
|
2364
|
+
---
|
|
2365
|
+
|
|
2179
2366
|
# Z3 SMT Solver Engine
|
|
2180
2367
|
|
|
2181
2368
|
**Z3** is a theorem prover and Satisfiability Modulo Theories (SMT) solver developed by Microsoft Research. In WaSHI, Z3 provides advanced propositional logic reasoning and conflict diagnosis.
|
|
@@ -2201,6 +2388,9 @@ npm install z3-solver
|
|
|
2201
2388
|
3. **Exact Mathematical Guarantees**:
|
|
2202
2389
|
Z3 can perform exact rational arithmetic without floating-point inaccuracies.
|
|
2203
2390
|
|
|
2391
|
+
> [!TIP]
|
|
2392
|
+
> **Z3 (`m.check()`) vs. HiGHS (`m.solve()`)**: Z3 excels at satisfiability checking (`m.check()`), formal policy verification, general inequality implications (`` implies(expr`${x} <= 50`, y) ``), and scale-invariant `unsatCore` extraction. For numerical LP, MIP, or Convex QP optimization with objective functions over tens or hundreds of variables, use the built-in **HiGHS** solver (`m.solve()`), whose simplex relaxations and branch-and-bound cutting planes are orders of magnitude faster than `Z3.Optimize`. Or combine both in a hybrid pipeline: verify/diagnose rules with Z3 `m.check()`, then optimize costs with HiGHS `m.solve()`.
|
|
2393
|
+
|
|
2204
2394
|
---
|
|
2205
2395
|
|
|
2206
2396
|
## Basic Usage
|
|
@@ -2854,21 +3044,21 @@ describe("Custom Solver Integration", () => {
|
|
|
2854
3044
|
|
|
2855
3045
|
# Examples & API Finder
|
|
2856
3046
|
|
|
2857
|
-
Explore runnable optimization and logic formulations built with WaSHI. Every example can be executed directly inside its documentation page, or edited in the [Playground](https://ohtaman.
|
|
3047
|
+
Explore runnable optimization and logic formulations built with WaSHI. Every example can be executed directly inside its documentation page, or edited in the [Playground](https://washi.ohtaman.dev/demo/).
|
|
2858
3048
|
|
|
2859
3049
|
## Examples
|
|
2860
3050
|
|
|
2861
3051
|
| Example | Problem Class | Solver | Key WaSHI APIs | Description | Code Editor |
|
|
2862
3052
|
| :--- | :--- | :--- | :--- | :--- | :--- |
|
|
2863
|
-
| **[Production Planning](docs/examples/production.md)** | LP / MIP | HiGHS | `m.integer()`, `m.constraint()`, `expr` | Basic variables, tagged template literal equations, and maximizing profit under resource limits. | [Playground](https://ohtaman.
|
|
2864
|
-
| **[0-1 Knapsack](docs/examples/knapsack.md)** | Binary MIP | HiGHS | `m.binary(keys)`, `
|
|
2865
|
-
| **[Transportation Network](docs/examples/transportation.md)** | LP | HiGHS | `m.continuous(rows, cols)`, `
|
|
2866
|
-
| **[Diet & Sensitivity Analysis](docs/examples/diet-sensitivity.md)** | LP | HiGHS | `constr.dual`, `constr.slack`, `var.reducedCost` | Reading shadow prices (bottlenecks), constraint surpluses, and reduced costs after solving. | [Playground](https://ohtaman.
|
|
2867
|
-
| **[Staff Shift Scheduling](docs/examples/shift-scheduling.md)** | Binary MIP + Unsat Core | HiGHS + Z3 | `m.binary(staff, days)`, `check()`, `unsatCore` | 5×7 weekly roster planner (coverage, senior duty, max shifts, day-off requests) and diagnosing conflicting vacation requests with Z3 `unsatCore`. | [Playground](https://ohtaman.
|
|
2868
|
-
| **[E-Commerce Coupon Optimizer](docs/examples/ecommerce-coupons.md)** | Indicator MIP | HiGHS | `m.indicator()`, `sum
|
|
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.
|
|
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.
|
|
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.
|
|
3053
|
+
| **[Production Planning](docs/examples/production.md)** | LP / MIP | HiGHS | `m.integer()`, `m.constraint()`, `expr` | Basic variables, tagged template literal equations, and maximizing profit under resource limits. | [Playground](https://washi.ohtaman.dev/demo/?preset=production) |
|
|
3054
|
+
| **[0-1 Knapsack](docs/examples/knapsack.md)** | Binary MIP | HiGHS | `m.binary(keys)`, `expr` (`dot`), `m.constraint()` | 1D dictionary of `Binary` variables and linear aggregation over item arrays. | [Playground](https://washi.ohtaman.dev/demo/?preset=knapsack) |
|
|
3055
|
+
| **[Transportation Network](docs/examples/transportation.md)** | LP | HiGHS | `m.continuous(rows, cols)`, `expr` (`sum`, `dot`), `m.constraint()` | 2D matrix indexing (`ship[factory][store]`) and row/column flow balance constraints. | [Playground](https://washi.ohtaman.dev/demo/?preset=transportation) |
|
|
3056
|
+
| **[Diet & Sensitivity Analysis](docs/examples/diet-sensitivity.md)** | LP | HiGHS | `constr.dual`, `constr.slack`, `var.reducedCost` | Reading shadow prices (bottlenecks), constraint surpluses, and reduced costs after solving. | [Playground](https://washi.ohtaman.dev/demo/?preset=diet_sensitivity) |
|
|
3057
|
+
| **[Staff Shift Scheduling](docs/examples/shift-scheduling.md)** | Binary MIP + Unsat Core | HiGHS + Z3 | `m.binary(staff, days)`, `check()`, `unsatCore` | 5×7 weekly roster planner (coverage, senior duty, max shifts, day-off requests) and diagnosing conflicting vacation requests with Z3 `unsatCore`. | [Playground](https://washi.ohtaman.dev/demo/?preset=shift_scheduling) |
|
|
3058
|
+
| **[E-Commerce Coupon Optimizer](docs/examples/ecommerce-coupons.md)** | Indicator MIP | HiGHS | `m.indicator()`, `expr` (`sum`, `dot`), `m.constraint()` | Checkout optimizer using `m.indicator()` for minimum spend thresholds, exclusive vs. stackable rules, and smart filler add-on suggestions. | [Playground](https://washi.ohtaman.dev/demo/?preset=cart_coupon) |
|
|
3059
|
+
| **[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://washi.ohtaman.dev/demo/?preset=portfolio_qp) |
|
|
3060
|
+
| **[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://washi.ohtaman.dev/demo/?preset=smt_security) |
|
|
3061
|
+
| **[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://washi.ohtaman.dev/demo/?preset=conflict_diagnosis_unsat) |
|
|
2872
3062
|
| **[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
3063
|
| **[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) |
|
|
2874
3064
|
|
|
@@ -2881,12 +3071,12 @@ Looking for a specific modeling method or solver capability? Jump straight to th
|
|
|
2881
3071
|
| ``m.constraint(expr`...`)`` | Tagged template literal constraints | [Production Planning](docs/examples/production.md) | [`01-production-planning.ts`](https://github.com/ohtaman/washi/blob/main/examples/node/01-production-planning.ts) |
|
|
2882
3072
|
| `m.binary(keys)` / `m.continuous(keys)` | 1D dictionary of indexed variables | [0-1 Knapsack](docs/examples/knapsack.md) | [`02-knapsack-problem.ts`](https://github.com/ohtaman/washi/blob/main/examples/node/02-knapsack-problem.ts) |
|
|
2883
3073
|
| `m.continuous(rows, cols)` / `m.binary(rows, cols)` | 2D matrix of indexed variables | [Transportation Network](docs/examples/transportation.md), [Shift Scheduling](docs/examples/shift-scheduling.md) | [`09-transportation-problem.ts`](https://github.com/ohtaman/washi/blob/main/examples/node/09-transportation-problem.ts) |
|
|
2884
|
-
| `dot(
|
|
3074
|
+
| ``expr`dot(${A}, ${X})` `` / `dot(A, X)` | Fast vector/matrix dot product | [Transportation Network](docs/examples/transportation.md), [Portfolio Optimization](docs/examples/portfolio-qp.md) | [`09-transportation-problem.ts`](https://github.com/ohtaman/washi/blob/main/examples/node/09-transportation-problem.ts) |
|
|
2885
3075
|
| `constr.dual` & `constr.slack` | Shadow prices & constraint surpluses | [Diet & Sensitivity Analysis](docs/examples/diet-sensitivity.md) | [`10-diet-sensitivity.ts`](https://github.com/ohtaman/washi/blob/main/examples/node/10-diet-sensitivity.ts) |
|
|
2886
3076
|
| `var.reducedCost` | Variable reduced costs in LP | [Diet & Sensitivity Analysis](docs/examples/diet-sensitivity.md) | [`10-diet-sensitivity.ts`](https://github.com/ohtaman/washi/blob/main/examples/node/10-diet-sensitivity.ts) |
|
|
2887
3077
|
| `m.indicator()` | Big-M conditional constraints (`binVar => constr`) | [E-Commerce Coupon Optimizer](docs/examples/ecommerce-coupons.md) | [`12-ecommerce-coupon-optimizer.ts`](https://github.com/ohtaman/washi/blob/main/examples/node/12-ecommerce-coupon-optimizer.ts) |
|
|
2888
3078
|
| `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) |
|
|
2889
|
-
| `m.bool()` &
|
|
3079
|
+
| `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) |
|
|
2890
3080
|
| `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
3081
|
| `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
3082
|
| `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) |
|
|
@@ -2908,6 +3098,8 @@ npx tsx examples/node/09-transportation-problem.ts
|
|
|
2908
3098
|
npx tsx examples/node/10-diet-sensitivity.ts
|
|
2909
3099
|
npx tsx examples/node/11-shift-scheduling.ts
|
|
2910
3100
|
npx tsx examples/node/12-ecommerce-coupon-optimizer.ts
|
|
3101
|
+
npx tsx examples/node/13-vehicle-routing-column-generation.ts
|
|
3102
|
+
npx tsx examples/node/14-traveling-salesperson-benders.ts
|
|
2911
3103
|
```
|
|
2912
3104
|
|
|
2913
3105
|
---
|
|
@@ -2916,7 +3108,7 @@ npx tsx examples/node/12-ecommerce-coupon-optimizer.ts
|
|
|
2916
3108
|
|
|
2917
3109
|
| Problem Class | Solver | Key APIs | Links |
|
|
2918
3110
|
| :--- | :--- | :--- | :--- |
|
|
2919
|
-
| Linear / Integer Programming (LP / MIP) | HiGHS (default) | `m.integer()`, `m.constraint()`, `expr` | <ul><li><a href="https://ohtaman.
|
|
3111
|
+
| Linear / Integer Programming (LP / MIP) | HiGHS (default) | `m.integer()`, `m.constraint()`, `expr` | <ul><li><a href="https://washi.ohtaman.dev/demo/?preset=production">Open in Playground</a></li><li><a href="https://github.com/ohtaman/washi/blob/main/examples/node/01-production-planning.ts">Source Code</a></li></ul> |
|
|
2920
3112
|
|
|
2921
3113
|
Production planning is a classic Linear Programming problem: determine the optimal quantity of each product to manufacture in order to maximize total profit while respecting resource constraints (raw materials, machine time, labor hours).
|
|
2922
3114
|
|
|
@@ -2946,9 +3138,9 @@ async function runProductionOptimization() {
|
|
|
2946
3138
|
// Objective: Maximize total profit ($45 per chair, $80 per table)
|
|
2947
3139
|
m.objective(maximize(expr`45 * ${chairs} + 80 * ${tables}`));
|
|
2948
3140
|
|
|
2949
|
-
// Resource constraints: declarative formulas via expr and
|
|
2950
|
-
m.constraint(expr`5 * ${chairs} + 20 * ${tables} <= 400`, "mahogany_limit")
|
|
2951
|
-
|
|
3141
|
+
// Resource constraints: declarative formulas via expr and constraint()
|
|
3142
|
+
m.constraint(expr`5 * ${chairs} + 20 * ${tables} <= 400`, "mahogany_limit");
|
|
3143
|
+
m.constraint(expr`10 * ${chairs} + 15 * ${tables} <= 450`, "labor_limit");
|
|
2952
3144
|
|
|
2953
3145
|
const status = await m.solve();
|
|
2954
3146
|
|
|
@@ -2966,7 +3158,7 @@ runProductionOptimization();
|
|
|
2966
3158
|
|
|
2967
3159
|
| Problem Class | Solver | Key APIs | Links |
|
|
2968
3160
|
| :--- | :--- | :--- | :--- |
|
|
2969
|
-
| 0-1 Binary MIP | HiGHS (default) | `m.binary()`, `m.constraint()`, `expr
|
|
3161
|
+
| 0-1 Binary MIP | HiGHS (default) | `m.binary()`, `m.constraint()`, `expr` (`dot`), `maximize` | <ul><li><a href="https://washi.ohtaman.dev/demo/?preset=knapsack">Open in Playground</a></li><li><a href="https://github.com/ohtaman/washi/blob/main/examples/node/02-knapsack-problem.ts">Source Code</a></li></ul> |
|
|
2970
3162
|
|
|
2971
3163
|
The 0-1 Knapsack problem is an essential combinatorial optimization problem: given a set of items, each with a weight and a value, determine which items to include in a collection so that the total weight does not exceed a given limit and the total value is maximized.
|
|
2972
3164
|
|
|
@@ -2983,7 +3175,7 @@ $$\begin{aligned}
|
|
|
2983
3175
|
## Implementation in WaSHI
|
|
2984
3176
|
|
|
2985
3177
|
```typescript
|
|
2986
|
-
import { Model, maximize,
|
|
3178
|
+
import { Model, maximize, expr } from "@ohtaman/washi";
|
|
2987
3179
|
|
|
2988
3180
|
async function solveKnapsack() {
|
|
2989
3181
|
const m = new Model("Knapsack");
|
|
@@ -3003,17 +3195,18 @@ async function solveKnapsack() {
|
|
|
3003
3195
|
const values = Object.fromEntries(items.map((it) => [it.name, it.value]));
|
|
3004
3196
|
const weights = Object.fromEntries(items.map((it) => [it.name, it.weight]));
|
|
3005
3197
|
|
|
3006
|
-
// Maximize total value via dot
|
|
3007
|
-
m.objective(maximize(dot(values, x)));
|
|
3198
|
+
// Maximize total value via dot() inside expr
|
|
3199
|
+
m.objective(maximize(expr`dot(${values}, ${x})`));
|
|
3008
3200
|
|
|
3009
|
-
// Weight capacity constraint using
|
|
3010
|
-
m.constraint(expr
|
|
3201
|
+
// Weight capacity constraint using expr and constraint()
|
|
3202
|
+
m.constraint(expr`dot(${weights}, ${x}) <= ${maxCapacity}`, "capacity_limit");
|
|
3011
3203
|
|
|
3012
3204
|
const status = await m.solve();
|
|
3013
3205
|
|
|
3014
3206
|
console.log(`Status: ${status}`);
|
|
3015
3207
|
console.log(`Max Total Value: $${m.objectiveValue}`);
|
|
3016
|
-
|
|
3208
|
+
const packed = items.filter((it) => x[it.name].value === 1).map((it) => it.name);
|
|
3209
|
+
console.log("Packed Items:", packed.join(", "));
|
|
3017
3210
|
}
|
|
3018
3211
|
|
|
3019
3212
|
solveKnapsack();
|
|
@@ -3025,7 +3218,7 @@ solveKnapsack();
|
|
|
3025
3218
|
|
|
3026
3219
|
| Problem Class | Solver | Key APIs | Links |
|
|
3027
3220
|
| :--- | :--- | :--- | :--- |
|
|
3028
|
-
| Linear Programming (LP) | HiGHS (default) | `m.continuous()`, `m.constraint()`, `expr
|
|
3221
|
+
| Linear Programming (LP) | HiGHS (default) | `m.continuous()`, `m.constraint()`, `expr` (`sum`, `dot`), `minimize` | <ul><li><a href="https://washi.ohtaman.dev/demo/?preset=transportation">Open in Playground</a></li><li><a href="https://github.com/ohtaman/washi/blob/main/examples/node/09-transportation-problem.ts">Source Code</a></li></ul> |
|
|
3029
3222
|
|
|
3030
3223
|
The transportation problem is one of the most fundamental network optimization models: determine how many units to ship from each supply origin (factory) to each destination (store) to minimize total shipping cost while respecting factory supply limits and meeting store demands.
|
|
3031
3224
|
|
|
@@ -3056,7 +3249,7 @@ $$\begin{aligned}
|
|
|
3056
3249
|
## Implementation in WaSHI
|
|
3057
3250
|
|
|
3058
3251
|
```typescript
|
|
3059
|
-
import { Model,
|
|
3252
|
+
import { Model, minimize, expr } from "@ohtaman/washi";
|
|
3060
3253
|
|
|
3061
3254
|
const factories = ["Tokyo", "Nagoya", "Osaka"] as const;
|
|
3062
3255
|
const stores = ["North", "East", "West", "South"] as const;
|
|
@@ -3070,51 +3263,78 @@ const shippingCost = {
|
|
|
3070
3263
|
const supplies = { Tokyo: 120, Nagoya: 100, Osaka: 80 };
|
|
3071
3264
|
const demands = { North: 70, East: 90, West: 60, South: 60 };
|
|
3072
3265
|
|
|
3073
|
-
async function
|
|
3266
|
+
async function solveScenario(label: string, cap: typeof supplies) {
|
|
3074
3267
|
const m = new Model("TransportationNetwork");
|
|
3075
3268
|
|
|
3076
3269
|
// 1. Create 2D continuous variables: ship[factory][store] >= 0
|
|
3077
3270
|
const ship = m.continuous([...factories], [...stores], { lb: 0 });
|
|
3078
3271
|
|
|
3079
3272
|
// 2. Objective: Minimize total shipping cost across all routes via 2D dot product
|
|
3080
|
-
m.objective(minimize(dot(shippingCost, ship)));
|
|
3273
|
+
m.objective(minimize(expr`dot(${shippingCost}, ${ship})`));
|
|
3081
3274
|
|
|
3082
|
-
// 3. Supply constraints (row sums): outgoing from f <=
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
`supply_${f}`
|
|
3087
|
-
)
|
|
3088
|
-
|
|
3275
|
+
// 3. Supply constraints (row sums): outgoing from f <= cap[f]
|
|
3276
|
+
const supplyConstrs = Object.fromEntries(
|
|
3277
|
+
factories.map((f) => [
|
|
3278
|
+
f,
|
|
3279
|
+
m.constraint(expr`sum(${ship[f]}) <= ${cap[f]}`, `supply_${f}`),
|
|
3280
|
+
])
|
|
3281
|
+
);
|
|
3089
3282
|
|
|
3090
3283
|
// 4. Demand constraints (column sums): incoming to s >= demands[s]
|
|
3091
|
-
|
|
3092
|
-
|
|
3093
|
-
|
|
3094
|
-
|
|
3095
|
-
|
|
3096
|
-
|
|
3284
|
+
const demandConstrs = Object.fromEntries(
|
|
3285
|
+
stores.map((s) => [
|
|
3286
|
+
s,
|
|
3287
|
+
m.constraint(
|
|
3288
|
+
expr`sum(${factories}, ${(f) => ship[f][s]}) >= ${demands[s]}`,
|
|
3289
|
+
`demand_${s}`
|
|
3290
|
+
),
|
|
3291
|
+
])
|
|
3292
|
+
);
|
|
3097
3293
|
|
|
3098
3294
|
const status = await m.solve();
|
|
3099
|
-
console.log(`Status: ${status}, Total Shipping Cost: $${m.objectiveValue}`);
|
|
3295
|
+
console.log(`[${label}] Status: ${status}, Total Shipping Cost: $${m.objectiveValue}`);
|
|
3100
3296
|
for (const f of factories) {
|
|
3297
|
+
const dual = supplyConstrs[f].dual ?? 0;
|
|
3101
3298
|
const routes = stores
|
|
3102
3299
|
.map((s) => `${s}:${Math.round(ship[f][s].value ?? 0)}`)
|
|
3103
3300
|
.join(", ");
|
|
3104
|
-
console.log(` ${f.padEnd(7)} -> ${routes}`);
|
|
3301
|
+
console.log(` ${f.padEnd(7)} -> ${routes} (supply dual: $${dual}/u)`);
|
|
3105
3302
|
}
|
|
3303
|
+
const demandDuals = stores
|
|
3304
|
+
.map((s) => `${s}:$${demandConstrs[s].dual ?? 0}`)
|
|
3305
|
+
.join(", ");
|
|
3306
|
+
console.log(` Marginal Delivered Cost (demand duals) -> ${demandDuals}`);
|
|
3307
|
+
}
|
|
3308
|
+
|
|
3309
|
+
async function runTransportation() {
|
|
3310
|
+
// Scenario A: Baseline capacities ($920)
|
|
3311
|
+
await solveScenario("Scenario A: Baseline", supplies);
|
|
3312
|
+
|
|
3313
|
+
// Scenario B: Tokyo capacity reduced (120 -> 80) due to maintenance ($1,000)
|
|
3314
|
+
await solveScenario("Scenario B: Tokyo Maintenance", {
|
|
3315
|
+
Tokyo: 80,
|
|
3316
|
+
Nagoya: 120,
|
|
3317
|
+
Osaka: 100,
|
|
3318
|
+
});
|
|
3106
3319
|
}
|
|
3107
3320
|
|
|
3108
3321
|
runTransportation();
|
|
3109
3322
|
```
|
|
3110
3323
|
|
|
3324
|
+
## Practical Notes
|
|
3325
|
+
|
|
3326
|
+
- **Interpreting Supply & Demand Shadow Prices (`.dual`)**:
|
|
3327
|
+
- **`demand_s.dual` ($\ge 0$)**: The marginal delivered cost of supplying 1 additional unit to store $s$.
|
|
3328
|
+
- **`supply_f.dual` ($\le 0$)**: How much total network shipping cost would decrease if factory $f$'s capacity were expanded by 1 unit. In Scenario B, when Tokyo is constrained at 80 units, `supply_Tokyo.dual` becomes negative (`-$2/u`), indicating that each extra unit of capacity in Tokyo saves \$2 by avoiding rerouting through Nagoya.
|
|
3329
|
+
- **Total Supply vs. Total Demand ($\sum \text{Supply}_f \ge \sum \text{Demand}_s$)**: If store demands exceed aggregate factory capacity, the hard demand constraints become `"Infeasible"`. In production logistics systems, add an elastic "spot-carrier / backorder" variable $u_s \ge 0$ to each store's demand constraint ($\sum_f x_{f,s} + u_s \ge \text{Demand}_s$) with a high penalty cost so the solver allocates scarce inventory optimally rather than failing.
|
|
3330
|
+
|
|
3111
3331
|
---
|
|
3112
3332
|
|
|
3113
3333
|
# Diet Problem & Sensitivity Analysis (LP Duals & Slacks)
|
|
3114
3334
|
|
|
3115
3335
|
| Problem Class | Solver | Key APIs | Links |
|
|
3116
3336
|
| :--- | :--- | :--- | :--- |
|
|
3117
|
-
| Linear Programming (LP) | HiGHS (default) | `m.continuous()`, `m.constraint()`, `expr
|
|
3337
|
+
| Linear Programming (LP) | HiGHS (default) | `m.continuous()`, `m.constraint()`, `expr` (`dot`), `minimize` | <ul><li><a href="https://washi.ohtaman.dev/demo/?preset=diet_sensitivity">Open in Playground</a></li><li><a href="https://github.com/ohtaman/washi/blob/main/examples/node/10-diet-sensitivity.ts">Source Code</a></li></ul> |
|
|
3118
3338
|
|
|
3119
3339
|
When solving a Linear Programming model, the optimal variable values (`var.value`) are only half the story. In practice, decision-makers need to know:
|
|
3120
3340
|
|
|
@@ -3138,7 +3358,7 @@ We choose daily servings ($0 \le x_i \le 4$) among 6 food items to meet 4 daily
|
|
|
3138
3358
|
## Implementation in WaSHI
|
|
3139
3359
|
|
|
3140
3360
|
```typescript
|
|
3141
|
-
import { Model, minimize,
|
|
3361
|
+
import { Model, minimize, expr } from "@ohtaman/washi";
|
|
3142
3362
|
|
|
3143
3363
|
const foods = [
|
|
3144
3364
|
{ name: "Oats", cost: 0.60, calories: 150, protein: 5, calcium: 20, iron: 1.8 },
|
|
@@ -3161,14 +3381,16 @@ async function runDietSensitivity() {
|
|
|
3161
3381
|
const calcs = Object.fromEntries(foods.map((f) => [f.name, f.calcium]));
|
|
3162
3382
|
const irons = Object.fromEntries(foods.map((f) => [f.name, f.iron]));
|
|
3163
3383
|
|
|
3164
|
-
// Objective: Minimize total daily cost via dot
|
|
3165
|
-
m.objective(minimize(dot(costs, srv)));
|
|
3384
|
+
// Objective: Minimize total daily cost via dot() inside expr
|
|
3385
|
+
m.objective(minimize(expr`dot(${costs}, ${srv})`));
|
|
3166
3386
|
|
|
3167
3387
|
// Nutritional constraints defined declaratively with expr and constraint()
|
|
3168
|
-
|
|
3169
|
-
|
|
3170
|
-
|
|
3171
|
-
|
|
3388
|
+
const nutConstraints = [
|
|
3389
|
+
m.constraint(expr`dot(${cals}, ${srv}) >= 2000`, "min_calories"),
|
|
3390
|
+
m.constraint(expr`dot(${prots}, ${srv}) >= 105`, "min_protein"),
|
|
3391
|
+
m.constraint(expr`dot(${calcs}, ${srv}) >= 1000`, "min_calcium"),
|
|
3392
|
+
m.constraint(expr`dot(${irons}, ${srv}) >= 18`, "min_iron"),
|
|
3393
|
+
];
|
|
3172
3394
|
|
|
3173
3395
|
const status = await m.solve();
|
|
3174
3396
|
|
|
@@ -3179,11 +3401,8 @@ async function runDietSensitivity() {
|
|
|
3179
3401
|
console.log(` ${f.name.padEnd(8)}: value=${v.value?.toFixed(2)}, reducedCost=${v.reducedCost?.toFixed(4)}`);
|
|
3180
3402
|
}
|
|
3181
3403
|
console.log("--- Nutritional Constraints (Dual & Slack) ---");
|
|
3182
|
-
for (const
|
|
3183
|
-
|
|
3184
|
-
if (c) {
|
|
3185
|
-
console.log(` ${c.name.padEnd(13)}: dual=$${c.dual?.toFixed(4)}, slack=${Math.abs(c.slack ?? 0).toFixed(2)}`);
|
|
3186
|
-
}
|
|
3404
|
+
for (const c of nutConstraints) {
|
|
3405
|
+
console.log(` ${c.name.padEnd(13)}: dual=$${c.dual?.toFixed(4)}, slack=${Math.abs(c.slack ?? 0).toFixed(2)}`);
|
|
3187
3406
|
}
|
|
3188
3407
|
}
|
|
3189
3408
|
|
|
@@ -3194,7 +3413,9 @@ runDietSensitivity();
|
|
|
3194
3413
|
|
|
3195
3414
|
- **Bottleneck constraints (`slack == 0`, `dual > 0`):** Calories (`2000 kcal`) and Iron (`18 mg`) are tight at their lower bounds. Increasing the daily Iron requirement by 1 mg increases the minimum daily diet cost by **$0.3241**.
|
|
3196
3415
|
- **Constraints with surplus (`slack > 0`, `dual == 0`):** Protein (`+46.46 g` surplus) and Calcium (`+483.02 mg` surplus) are already exceeded with room to spare, so their shadow prices are `$0`.
|
|
3197
|
-
- **Reduced cost (`Salmon`):** Unselected `Salmon` (`0.00` servings) has a reduced cost of `+1.5125`, meaning its price per serving would need to drop by at least **$1.51** before it enters the optimal daily menu.
|
|
3416
|
+
- **Reduced cost at lower bound (`Salmon`):** Unselected `Salmon` (`0.00` servings) has a reduced cost of `+1.5125`, meaning its price per serving would need to drop by at least **$1.51** before it enters the optimal daily menu.
|
|
3417
|
+
- **Negative reduced cost at upper bound (`Oats`, `Eggs`):** Both `Oats` (`reducedCost = -0.2581`) and `Eggs` (`reducedCost = -0.0887`) are pinned against their upper serving cap (`ub = 4.0`). In a minimization LP, a negative reduced cost on an upper-bounded variable tells you how much the total objective would **decrease** per unit increase in `ub`—raising the daily cap on `Oats` from 4 to 5 servings would save **$0.26/day**.
|
|
3418
|
+
- **Basis stability range:** Shadow prices (`dual`) and reduced costs (`reducedCost`) represent local marginal rates of change within the current optimal simplex basis. If a right-hand side or bound changes enough to trigger a basis change (for example, once another variable hits its bound), the marginal rate shifts to a new piece of the piecewise-linear optimal cost curve.
|
|
3198
3419
|
|
|
3199
3420
|
---
|
|
3200
3421
|
|
|
@@ -3202,7 +3423,7 @@ runDietSensitivity();
|
|
|
3202
3423
|
|
|
3203
3424
|
| Problem Class | Solver | Key APIs | Links |
|
|
3204
3425
|
| :--- | :--- | :--- | :--- |
|
|
3205
|
-
| Binary MIP & SMT Unsat Core | HiGHS + Z3 | `m.binary(staff, days)`, `m.constraint()`, `expr
|
|
3426
|
+
| Binary MIP & SMT Unsat Core | HiGHS + Z3 | `m.binary(staff, days)`, `m.constraint()`, `expr` (`sum`, `dot`), `minimize`, `m.check()` | <ul><li><a href="https://washi.ohtaman.dev/demo/?preset=shift_scheduling">Playground (Roster)</a></li><li><a href="https://washi.ohtaman.dev/demo/?preset=shift_conflict_unsat">Playground (Conflict)</a></li><li><a href="https://github.com/ohtaman/washi/blob/main/examples/node/11-shift-scheduling.ts">Source Code</a></li></ul> |
|
|
3206
3427
|
|
|
3207
3428
|
Staff shift scheduling is a classic real-world application of Mixed-Integer Programming in SaaS and workforce management tools. However, in real applications, **staff vacation requests frequently collide with minimum coverage or leadership rules**, causing the solver to return `Infeasible`.
|
|
3208
3429
|
|
|
@@ -3231,7 +3452,7 @@ We evaluate two distinct, named scenarios with different vacation inputs to illu
|
|
|
3231
3452
|
## Implementation in WaSHI
|
|
3232
3453
|
|
|
3233
3454
|
```typescript
|
|
3234
|
-
import { Model,
|
|
3455
|
+
import { Model, minimize, expr } from "@ohtaman/washi";
|
|
3235
3456
|
import "@ohtaman/washi/solvers/z3";
|
|
3236
3457
|
|
|
3237
3458
|
const days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] as const;
|
|
@@ -3264,12 +3485,12 @@ function buildShiftModel(name: string, dayOffRequests: Array<{ staff: string; da
|
|
|
3264
3485
|
),
|
|
3265
3486
|
])
|
|
3266
3487
|
);
|
|
3267
|
-
m.objective(minimize(dot(costMatrix, shift)));
|
|
3488
|
+
m.objective(minimize(expr`dot(${costMatrix}, ${shift})`));
|
|
3268
3489
|
|
|
3269
3490
|
// Rule 1: Daily headcount coverage
|
|
3270
3491
|
for (const d of days) {
|
|
3271
3492
|
m.constraint(
|
|
3272
|
-
expr
|
|
3493
|
+
expr`sum(${staffList}, ${(s) => shift[s.name][d]}) >= ${requiredCoverage[d]}`,
|
|
3273
3494
|
`coverage_${d}`
|
|
3274
3495
|
);
|
|
3275
3496
|
}
|
|
@@ -3278,7 +3499,7 @@ function buildShiftModel(name: string, dayOffRequests: Array<{ staff: string; da
|
|
|
3278
3499
|
const seniors = staffList.filter((s) => s.isSenior);
|
|
3279
3500
|
for (const d of days) {
|
|
3280
3501
|
m.constraint(
|
|
3281
|
-
expr
|
|
3502
|
+
expr`sum(${seniors}, ${(s) => shift[s.name][d]}) >= 1`,
|
|
3282
3503
|
`senior_duty_${d}`
|
|
3283
3504
|
);
|
|
3284
3505
|
}
|
|
@@ -3286,7 +3507,7 @@ function buildShiftModel(name: string, dayOffRequests: Array<{ staff: string; da
|
|
|
3286
3507
|
// Rule 3: Max working days per staff member
|
|
3287
3508
|
for (const s of staffList) {
|
|
3288
3509
|
m.constraint(
|
|
3289
|
-
expr
|
|
3510
|
+
expr`sum(${shift[s.name]}) <= ${s.maxShifts}`,
|
|
3290
3511
|
`max_shifts_${s.name}`
|
|
3291
3512
|
);
|
|
3292
3513
|
}
|
|
@@ -3322,7 +3543,7 @@ async function runScenario(
|
|
|
3322
3543
|
if (status === "Optimal") {
|
|
3323
3544
|
console.log(`Optimal Weekly Payroll: $${model.objectiveValue}`);
|
|
3324
3545
|
for (const s of staffList) {
|
|
3325
|
-
const schedule = days.map((d) => (
|
|
3546
|
+
const schedule = days.map((d) => (shift[s.name][d].value === 1 ? "ON" : " ·"));
|
|
3326
3547
|
console.log(` ${s.name.padEnd(8)}: ${schedule.join(" ")}`);
|
|
3327
3548
|
}
|
|
3328
3549
|
console.log("Schedule resolved successfully (no UNSAT diagnosis needed).\n");
|
|
@@ -3368,13 +3589,26 @@ async function main() {
|
|
|
3368
3589
|
main();
|
|
3369
3590
|
```
|
|
3370
3591
|
|
|
3592
|
+
## Auto-Repairing Conflicts with Soft Constraints (Elastic Requests)
|
|
3593
|
+
|
|
3594
|
+
Once Z3 `unsatCore` diagnoses that `senior_duty_Sat` conflicts with `day_off_Alice_Sat` and `day_off_Bob_Sat`, you can offer managers an automatic repair option by relaxing day-off requests from hard constraints (`shift[staff][day] == 0`) into **soft preferences** penalized in the objective (e.g., `+$500` per overridden day off):
|
|
3595
|
+
|
|
3596
|
+
```typescript
|
|
3597
|
+
// Soft constraint pattern: penalize working on a requested day off by $500
|
|
3598
|
+
// instead of enforcing hard shift[req.staff][req.day] == 0
|
|
3599
|
+
const requestedShiftVars = dayOffRequests.map((req) => shift[req.staff][req.day]);
|
|
3600
|
+
m.objective(minimize(expr`dot(${costMatrix}, ${shift}) + 500 * sum(${requestedShiftVars})`));
|
|
3601
|
+
```
|
|
3602
|
+
|
|
3603
|
+
With this soft formulation on Scenario B, HiGHS automatically finds a valid roster that honors 3 of the 4 requests (`Alice (Sat)`, `Charlie (Sat)`, and `Diana (Wed)`) and overrides only `Bob (Sat)`—choosing Bob over Alice because Bob's daily wage (`$210`) is `$10` lower than Alice's (`$220`).
|
|
3604
|
+
|
|
3371
3605
|
---
|
|
3372
3606
|
|
|
3373
3607
|
# E-Commerce Cart & Coupon Optimizer (Indicator MIP)
|
|
3374
3608
|
|
|
3375
3609
|
| Problem Class | Solver | Key APIs | Links |
|
|
3376
3610
|
| :--- | :--- | :--- | :--- |
|
|
3377
|
-
| Mixed-Integer Programming (MIP) | HiGHS (default) | `m.binary()`, `m.constraint()`, `m.indicator()`, `expr`, `minimize` | <ul><li><a href="https://ohtaman.
|
|
3611
|
+
| Mixed-Integer Programming (MIP) | HiGHS (default) | `m.binary()`, `m.constraint()`, `m.indicator()`, `expr`, `minimize` | <ul><li><a href="https://washi.ohtaman.dev/demo/?preset=cart_coupon">Open in Playground</a></li><li><a href="https://github.com/ohtaman/washi/blob/main/examples/node/12-ecommerce-coupon-optimizer.ts">Source Code</a></li></ul> |
|
|
3378
3612
|
|
|
3379
3613
|
Modern e-commerce checkouts often feature complex promotional rules: minimum spend thresholds, category-specific discounts, stackable vs. mutually exclusive coupons, and maximum coupon limits per order. Running an optimizer directly in the browser (or an edge worker) lets the checkout UI automatically apply the best coupon combination—and even recommend a small "filler" item when crossing a spend threshold lowers the customer's net bill.
|
|
3380
3614
|
|
|
@@ -3399,7 +3633,7 @@ By adding the `$10` cable (`$252` gross), the customer unlocks `MEGA45_250` (`-$
|
|
|
3399
3633
|
## Implementation in WaSHI
|
|
3400
3634
|
|
|
3401
3635
|
```typescript
|
|
3402
|
-
import { Model,
|
|
3636
|
+
import { Model, minimize, expr } from "@ohtaman/washi";
|
|
3403
3637
|
|
|
3404
3638
|
async function optimizeCheckoutCart() {
|
|
3405
3639
|
const m = new Model("CartCouponOptimizer");
|
|
@@ -3420,50 +3654,43 @@ async function optimizeCheckoutCart() {
|
|
|
3420
3654
|
|
|
3421
3655
|
// Binary decision variables for each coupon
|
|
3422
3656
|
const useCoupon = m.binary(coupons.map((c) => c.id));
|
|
3657
|
+
const discounts = Object.fromEntries(coupons.map((c) => [c.id, c.discount]));
|
|
3423
3658
|
|
|
3424
|
-
const merchSpend = addUsbCable
|
|
3425
|
-
const techSpend = addUsbCable
|
|
3659
|
+
const merchSpend = expr`${baseCartSubtotal} + ${usbCablePrice} * ${addUsbCable}`;
|
|
3660
|
+
const techSpend = expr`${baseTechSubtotal} + ${usbCablePrice} * ${addUsbCable}`;
|
|
3426
3661
|
|
|
3427
|
-
// 1. Indicator constraints: If useCoupon[id] == 1, minimum spend threshold must hold
|
|
3662
|
+
// 1. Indicator constraints: If useCoupon[id] == 1, minimum spend threshold must hold.
|
|
3663
|
+
// Omitting `M` lets WaSHI automatically derive the tightest Big-M from variable bounds!
|
|
3428
3664
|
for (const cpn of coupons) {
|
|
3429
3665
|
if (cpn.minCart > 0) {
|
|
3430
|
-
m.indicator(useCoupon[cpn.id], merchSpend
|
|
3666
|
+
m.indicator(useCoupon[cpn.id], expr`${merchSpend} >= ${cpn.minCart}`, {
|
|
3431
3667
|
activeValue: 1,
|
|
3432
|
-
M: 500,
|
|
3433
3668
|
name: `min_spend_${cpn.id}`,
|
|
3434
3669
|
});
|
|
3435
3670
|
}
|
|
3436
3671
|
if (cpn.minTech > 0) {
|
|
3437
|
-
m.indicator(useCoupon[cpn.id], techSpend
|
|
3672
|
+
m.indicator(useCoupon[cpn.id], expr`${techSpend} >= ${cpn.minTech}`, {
|
|
3438
3673
|
activeValue: 1,
|
|
3439
|
-
M: 500,
|
|
3440
3674
|
name: `min_tech_${cpn.id}`,
|
|
3441
3675
|
});
|
|
3442
3676
|
}
|
|
3443
3677
|
}
|
|
3444
3678
|
|
|
3445
3679
|
// 2. Mutual exclusion among non-stackable ("Exclusive") coupons
|
|
3446
|
-
const
|
|
3447
|
-
m.constraint(
|
|
3448
|
-
expr`${sum(exclusiveCoupons, (c) => useCoupon[c.id])} <= 1`,
|
|
3449
|
-
"at_most_one_exclusive"
|
|
3450
|
-
);
|
|
3680
|
+
const exclusiveVars = coupons.filter((c) => !c.stackable).map((c) => useCoupon[c.id]);
|
|
3681
|
+
m.constraint(expr`sum(${exclusiveVars}) <= 1`, "at_most_one_exclusive");
|
|
3451
3682
|
|
|
3452
3683
|
// 3. Max 2 coupons per order
|
|
3453
|
-
m.constraint(
|
|
3454
|
-
expr`${sum(coupons, (c) => useCoupon[c.id])} <= 2`,
|
|
3455
|
-
"max_two_coupons"
|
|
3456
|
-
);
|
|
3684
|
+
m.constraint(expr`sum(${useCoupon}) <= 2`, "max_two_coupons");
|
|
3457
3685
|
|
|
3458
3686
|
// Objective: Minimize final net payable amount (merchSpend - totalDiscounts)
|
|
3459
|
-
|
|
3460
|
-
m.objective(minimize(merchSpend.sub(totalDiscount)));
|
|
3687
|
+
m.objective(minimize(expr`${merchSpend} - dot(${discounts}, ${useCoupon})`));
|
|
3461
3688
|
|
|
3462
3689
|
const status = await m.solve();
|
|
3463
3690
|
console.log(`Status: ${status}, Optimal Net Payable: $${m.objectiveValue}`);
|
|
3464
|
-
console.log(`Recommended Add-on (+$10 USB-C Cable): ${
|
|
3691
|
+
console.log(`Recommended Add-on (+$10 USB-C Cable): ${addUsbCable.value === 1 ? "YES" : "NO"}`);
|
|
3465
3692
|
for (const cpn of coupons) {
|
|
3466
|
-
const applied =
|
|
3693
|
+
const applied = useCoupon[cpn.id].value === 1;
|
|
3467
3694
|
console.log(` ${applied ? "✅ [APPLIED]" : " [skipped]"} ${cpn.id.padEnd(11)} (-$${cpn.discount})`);
|
|
3468
3695
|
}
|
|
3469
3696
|
}
|
|
@@ -3477,7 +3704,7 @@ optimizeCheckoutCart();
|
|
|
3477
3704
|
|
|
3478
3705
|
| Problem Class | Solver | Key APIs | Links |
|
|
3479
3706
|
| :--- | :--- | :--- | :--- |
|
|
3480
|
-
| Convex Quadratic Programming (QP) | HiGHS (default) | `m.continuous()`, `m.constraint()`, `expr`, `minimize`, `dot`, `QuadExpr` | <ul><li><a href="https://ohtaman.
|
|
3707
|
+
| Convex Quadratic Programming (QP) | HiGHS (default) | `m.continuous()`, `m.constraint()`, `expr`, `minimize`, `dot`, `QuadExpr` | <ul><li><a href="https://washi.ohtaman.dev/demo/?preset=portfolio_qp">Open in Playground</a></li><li><a href="https://github.com/ohtaman/washi/blob/main/examples/node/03-portfolio-qp.ts">Source Code</a></li></ul> |
|
|
3481
3708
|
|
|
3482
3709
|
The Markowitz mean-variance portfolio optimization problem is the foundation of modern quantitative finance: allocate capital across risky assets to minimize overall portfolio variance (risk) subject to achieving a minimum required expected return.
|
|
3483
3710
|
|
|
@@ -3497,7 +3724,7 @@ where $\boldsymbol{\Sigma}$ is the positive semi-definite asset return covarianc
|
|
|
3497
3724
|
## Implementation in WaSHI
|
|
3498
3725
|
|
|
3499
3726
|
```typescript
|
|
3500
|
-
import { Model, QuadExpr,
|
|
3727
|
+
import { Model, QuadExpr, dot, minimize, expr } from "@ohtaman/washi";
|
|
3501
3728
|
|
|
3502
3729
|
async function solvePortfolio() {
|
|
3503
3730
|
const m = new Model("PortfolioQP");
|
|
@@ -3519,11 +3746,12 @@ async function solvePortfolio() {
|
|
|
3519
3746
|
const w = m.continuous(assets, { lb: 0, ub: 1 });
|
|
3520
3747
|
|
|
3521
3748
|
// 1. Budget constraint: sum(w_i) == 1.0 (100%)
|
|
3522
|
-
m.constraint(expr
|
|
3749
|
+
m.constraint(expr`sum(${w}) == 1.0`, "budget_limit");
|
|
3523
3750
|
|
|
3524
3751
|
// 2. Expected return constraint: sum(r_i * w_i) >= 8.5%
|
|
3525
3752
|
const retMap = Object.fromEntries(assets.map((a, i) => [a, expectedReturns[i]]));
|
|
3526
|
-
|
|
3753
|
+
const portfolioReturn = dot(retMap, w);
|
|
3754
|
+
m.constraint(expr`${portfolioReturn} >= ${targetReturn}`, "target_return");
|
|
3527
3755
|
|
|
3528
3756
|
// 3. Quadratic Objective: minimize risk w^T * Sigma * w
|
|
3529
3757
|
let risk = new QuadExpr();
|
|
@@ -3542,6 +3770,7 @@ async function solvePortfolio() {
|
|
|
3542
3770
|
console.log(`Status: ${status}`);
|
|
3543
3771
|
console.log(`Variance: ${m.objectiveValue?.toFixed(6)}`);
|
|
3544
3772
|
console.log(`Volatility (Std Dev): ${((Math.sqrt(m.objectiveValue || 0)) * 100).toFixed(2)}%`);
|
|
3773
|
+
console.log(`Expected Return: ${((portfolioReturn.value ?? 0) * 100).toFixed(2)}% (target >= ${(targetReturn * 100).toFixed(2)}%)`);
|
|
3545
3774
|
|
|
3546
3775
|
assets.forEach((name) => {
|
|
3547
3776
|
console.log(` ${name.padEnd(10)}: ${((w[name].value ?? 0) * 100).toFixed(2)}%`);
|
|
@@ -3551,13 +3780,18 @@ async function solvePortfolio() {
|
|
|
3551
3780
|
solvePortfolio();
|
|
3552
3781
|
```
|
|
3553
3782
|
|
|
3783
|
+
## Practical Notes
|
|
3784
|
+
|
|
3785
|
+
- **Tracing the Efficient Frontier (`targetReturn` & `portfolioReturn.value`)**: Because the achieved return (`8.50%`) matches `targetReturn` exactly, the return constraint is tightly binding on the Markowitz efficient frontier. Varying `targetReturn` across `[0.05, 0.12]` traces out the full risk-return frontier.
|
|
3786
|
+
- **Risk-Aversion Scalarization vs. Target Return Constraint**: If `targetReturn` is set above the highest single-asset return ($12\%$ for `Tech` under long-only weights), the hard constraint $\mathbf{r}^T \mathbf{w} \ge R_{\text{target}}$ becomes `"Infeasible"`. An alternative formulation that is always feasible for any risk appetite $\lambda > 0$ is to minimize the risk-adjusted objective $\min (\lambda \, \mathbf{w}^T \boldsymbol{\Sigma} \mathbf{w} - \mathbf{r}^T \mathbf{w})$.
|
|
3787
|
+
|
|
3554
3788
|
---
|
|
3555
3789
|
|
|
3556
3790
|
# SMT Security Policies
|
|
3557
3791
|
|
|
3558
3792
|
| Problem Class | Solver | Key APIs | Links |
|
|
3559
3793
|
| :--- | :--- | :--- | :--- |
|
|
3560
|
-
| Propositional Logic & SMT | Z3 (`@ohtaman/washi/solvers/z3`) | `m.bool()`, `m.integer()`, `m.constraint()`, `expr`, `m.check()` | <ul><li><a href="https://ohtaman.
|
|
3794
|
+
| Propositional Logic & SMT | Z3 (`@ohtaman/washi/solvers/z3`) | `m.bool()`, `m.integer()`, `implies()`, `m.constraint()`, `expr`, `m.check()` | <ul><li><a href="https://washi.ohtaman.dev/demo/?preset=smt_security">Open in Playground</a></li><li><a href="https://github.com/ohtaman/washi/blob/main/examples/node/04-smt-security-rules.ts">Source Code</a></li></ul> |
|
|
3561
3795
|
|
|
3562
3796
|
Satisfiability Modulo Theories (SMT) combines propositional logic (Boolean SAT) with first-order theories (linear integer arithmetic, real inequalities).
|
|
3563
3797
|
|
|
@@ -3574,7 +3808,7 @@ We model a security rule system with sensor readings and logic rules:
|
|
|
3574
3808
|
## Implementation in WaSHI
|
|
3575
3809
|
|
|
3576
3810
|
```typescript
|
|
3577
|
-
import { Model, not, expr } from "@ohtaman/washi";
|
|
3811
|
+
import { Model, implies, not, expr } from "@ohtaman/washi";
|
|
3578
3812
|
import "@ohtaman/washi/solvers/z3";
|
|
3579
3813
|
|
|
3580
3814
|
async function verifySecurityRules() {
|
|
@@ -3592,7 +3826,7 @@ async function verifySecurityRules() {
|
|
|
3592
3826
|
const luxLevel = m.integer("luxLevel", { lb: 0, ub: 1000 });
|
|
3593
3827
|
|
|
3594
3828
|
// Rule 1: luxLevel <= 50 implies isNight
|
|
3595
|
-
m.constraint(luxLevel
|
|
3829
|
+
m.constraint(implies(expr`${luxLevel} <= 50`, isNight), "dark_implies_night");
|
|
3596
3830
|
|
|
3597
3831
|
// Rule 2: (motionDetected AND isNight AND alarmArmed) => sirenActive
|
|
3598
3832
|
m.constraint(
|
|
@@ -3619,20 +3853,32 @@ async function verifySecurityRules() {
|
|
|
3619
3853
|
console.log(`Lights On : ${lightsOn.value}`);
|
|
3620
3854
|
console.log(`Door Locked : ${frontDoorUnlocked.value ? "No" : "Yes"}`);
|
|
3621
3855
|
console.log(`Is Night : ${isNight.value}`);
|
|
3856
|
+
|
|
3857
|
+
// 4. Formal Safety Verification (Counterexample Search):
|
|
3858
|
+
// Prove that the siren CANNOT remain off under these sensor readings
|
|
3859
|
+
m.constraint(not(sirenActive), "counterexample_siren_off");
|
|
3860
|
+
const proof = await m.check({ solver: "z3" });
|
|
3861
|
+
console.log(`Can siren stay off? ${proof.status} (Proven safe when Unsat)`);
|
|
3862
|
+
console.log(`Active proof core : ${(proof.conflicts ?? []).join(", ")}`);
|
|
3622
3863
|
}
|
|
3623
3864
|
|
|
3624
3865
|
verifySecurityRules();
|
|
3625
3866
|
```
|
|
3626
3867
|
|
|
3868
|
+
## Practical Notes
|
|
3869
|
+
|
|
3870
|
+
- **Proof by Refutation (Counterexample Search)**: A single `Sat` check shows that *a* valid assignment exists. To formally prove that a safety property $P$ (such as `sirenActive`) **must** hold in *every* valid state, assert its negation `not(P)` and verify that `m.check()` returns `"Unsat"`. If it returns `"Sat"`, the solver's variable values give you a concrete counterexample (security loophole).
|
|
3871
|
+
- **One-Way Implication (`implies`) vs. Equivalence**: In classical logic, `` implies(expr`${luxLevel} <= 50`, isNight) `` only constrains `isNight` when `luxLevel <= 50`. During daytime (`luxLevel > 50`), `isNight` is unconstrained ("open-world" assumption) and could be assigned either `true` or `false` unless you also add the converse rule `` implies(expr`${luxLevel} >= 51`, not(isNight)) ``.
|
|
3872
|
+
|
|
3627
3873
|
---
|
|
3628
3874
|
|
|
3629
3875
|
# Conflict Diagnosis (Unsat Core)
|
|
3630
3876
|
|
|
3631
3877
|
| Problem Class | Solver | Key APIs | Links |
|
|
3632
3878
|
| :--- | :--- | :--- | :--- |
|
|
3633
|
-
| Infeasibility Diagnosis / Unsat Core | Z3 (`@ohtaman/washi/solvers/z3`) | `m.integer()`, `m.continuous()`, `m.constraint()`, `expr`, `m.check()`, `m.conflicts` | <ul><li><a href="https://ohtaman.
|
|
3879
|
+
| Infeasibility Diagnosis / Unsat Core | Z3 (`@ohtaman/washi/solvers/z3`) | `m.integer()`, `m.continuous()`, `implies()`, `m.constraint()`, `expr`, `m.check()`, `m.conflicts` | <ul><li><a href="https://washi.ohtaman.dev/demo/?preset=conflict_diagnosis_unsat">Open in Playground</a></li><li><a href="https://github.com/ohtaman/washi/blob/main/examples/node/05-unsat-core-diagnosis.ts">Source Code</a></li></ul> |
|
|
3634
3880
|
|
|
3635
|
-
When business rules, budgets, and operational policies grow complex, constraints
|
|
3881
|
+
When business rules, budgets, and operational policies grow complex, constraints can easily conflict. Because a standard optimization call (`m.solve()`) only reports `"Infeasible"`, WaSHI provides `m.check()` to pinpoint conflicting rules without manual trial-and-error.
|
|
3636
3882
|
|
|
3637
3883
|
This example shows how to isolate the conflicting constraint subset using Z3 Unsat Core extraction.
|
|
3638
3884
|
|
|
@@ -3652,7 +3898,7 @@ $$8 \text{ engineers} \times \$8,000 = \$64,000 > \$50,000 \text{ budget!}$$
|
|
|
3652
3898
|
## Implementation in WaSHI
|
|
3653
3899
|
|
|
3654
3900
|
```typescript
|
|
3655
|
-
import { Model, expr } from "@ohtaman/washi";
|
|
3901
|
+
import { Model, implies, expr } from "@ohtaman/washi";
|
|
3656
3902
|
import "@ohtaman/washi/solvers/z3";
|
|
3657
3903
|
|
|
3658
3904
|
async function diagnoseConflicts() {
|
|
@@ -3669,7 +3915,7 @@ async function diagnoseConflicts() {
|
|
|
3669
3915
|
m.constraint(rushDelivery, "rush_required");
|
|
3670
3916
|
|
|
3671
3917
|
// Rule 3: Rush delivery requires at least 8 engineers
|
|
3672
|
-
m.constraint(
|
|
3918
|
+
m.constraint(implies(rushDelivery, expr`${engineers} >= 8`), "rush_staffing_rule");
|
|
3673
3919
|
|
|
3674
3920
|
// Rule 4: Board budget cap of $50,000 (Conflict!)
|
|
3675
3921
|
m.constraint(expr`${budget} <= 50000`, "strict_board_budget_cap");
|
|
@@ -3681,19 +3927,29 @@ async function diagnoseConflicts() {
|
|
|
3681
3927
|
const res = await m.check({ solver: "z3" });
|
|
3682
3928
|
|
|
3683
3929
|
console.log(`Status: ${res.status}`);
|
|
3684
|
-
console.log("
|
|
3930
|
+
console.log("Z3 Unsat Core (scale-invariant):", m.conflicts);
|
|
3931
|
+
|
|
3932
|
+
// Built-in HiGHS can also diagnose conflicts via single-solve L1 elastic slack relaxation:
|
|
3933
|
+
const highsDiag = await m.check({ solver: "highs" });
|
|
3934
|
+
console.log("HiGHS Elastic Repair Set:", highsDiag.conflicts);
|
|
3685
3935
|
}
|
|
3686
3936
|
|
|
3687
3937
|
diagnoseConflicts();
|
|
3688
3938
|
```
|
|
3689
3939
|
|
|
3940
|
+
## Practical Notes
|
|
3941
|
+
|
|
3942
|
+
- **Z3 `unsatCore` vs. HiGHS Elastic Slack `conflicts`**:
|
|
3943
|
+
- **Z3 (`m.check({ solver: "z3" })`)** performs logical assumption tracking and returns all four rules that jointly form the contradiction (`budget_limit`, `rush_required`, `rush_staffing_rule`, `strict_board_budget_cap`), while excluding the innocent `minimum_one_employee` rule.
|
|
3944
|
+
- **HiGHS (`m.check({ solver: "highs" })`)** solves a single $L_1$ elastic slack minimization problem ($\min \sum s_i$). Because relaxing `rush_staffing_rule` (or `rush_required`) requires a slack of only `1`–`2` units whereas relaxing `strict_board_budget_cap` requires a slack of `14,000` dollars, unweighted $L_1$ minimization naturally highlights the smallest-magnitude repair (`rush_required`) needed to restore feasibility.
|
|
3945
|
+
|
|
3690
3946
|
---
|
|
3691
3947
|
|
|
3692
3948
|
# Capacitated Vehicle Routing Problem (CVRP, 50 Customers via Column Generation)
|
|
3693
3949
|
|
|
3694
3950
|
| Problem Class | Solver | Key APIs | Links |
|
|
3695
3951
|
| :--- | :--- | :--- | :--- |
|
|
3696
|
-
| Combinatorial OR / Dantzig-Wolfe | HiGHS (default) | `m.solveColumnGeneration()`, `pricing`, `
|
|
3952
|
+
| Combinatorial OR / Dantzig-Wolfe | HiGHS (default) | `m.solveColumnGeneration()`, `pricing`, `m.binary()`, `m.constraint()`, `expr` (`sum`) | <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">Decomposition Guide</a></li></ul> |
|
|
3697
3953
|
|
|
3698
3954
|
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
3955
|
|
|
@@ -3713,7 +3969,7 @@ We consider a distribution network with a central depot at $(0, 0)$ and **50 cus
|
|
|
3713
3969
|
## Implementation in WaSHI
|
|
3714
3970
|
|
|
3715
3971
|
```typescript
|
|
3716
|
-
import { Model, expr, minimize
|
|
3972
|
+
import { Model, expr, minimize } from "@ohtaman/washi";
|
|
3717
3973
|
|
|
3718
3974
|
// --- Extracted Routing Helpers ---
|
|
3719
3975
|
|
|
@@ -3830,13 +4086,12 @@ async function runCVRP() {
|
|
|
3830
4086
|
|
|
3831
4087
|
// Initial naive routes: 1 dedicated vehicle per customer
|
|
3832
4088
|
const initialRoutes = customers.map((c) => master.binary(`route_init_${c.id}`));
|
|
3833
|
-
master.objective(minimize(sum(initialRoutes)));
|
|
4089
|
+
master.objective(minimize(expr`sum(${initialRoutes})`));
|
|
3834
4090
|
|
|
3835
4091
|
// Each customer must be visited by exactly 1 vehicle
|
|
3836
|
-
|
|
3837
|
-
master.constraint(expr`${initialRoutes[i]} == 1`, `visit_${
|
|
3838
|
-
|
|
3839
|
-
const visitConstrs = master.constraints;
|
|
4092
|
+
const visitConstrs = customers.map((c, i) =>
|
|
4093
|
+
master.constraint(expr`${initialRoutes[i]} == 1`, `visit_${c.id}`)
|
|
4094
|
+
);
|
|
3840
4095
|
|
|
3841
4096
|
console.log(`Initial state: 50 naive single-customer routes (50 vehicles).`);
|
|
3842
4097
|
console.log("Solving via Column Generation with dual-directed heuristic pricing...");
|
|
@@ -3876,7 +4131,7 @@ async function runCVRP() {
|
|
|
3876
4131
|
console.log(`Total Columns Generated: ${result.columnsAdded}`);
|
|
3877
4132
|
|
|
3878
4133
|
console.log("\nSample routes selected in fleet:");
|
|
3879
|
-
const activeRoutes = result.generatedColumns.filter((c) =>
|
|
4134
|
+
const activeRoutes = result.generatedColumns.filter((c) => c.variable.value === 1);
|
|
3880
4135
|
for (const r of activeRoutes.slice(0, 5)) {
|
|
3881
4136
|
const meta = r.proposal.meta;
|
|
3882
4137
|
console.log(` ${meta?.tour.join(" -> ")} (Transit: ${meta?.travelTime}m, Total: ${meta?.totalTime}m)`);
|
|
@@ -3891,6 +4146,9 @@ runCVRP();
|
|
|
3891
4146
|
- **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
4147
|
- **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
4148
|
- **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.
|
|
4149
|
+
- **Set Partitioning (`== 1`) vs. Set Covering (`>= 1`)**: This example uses `== 1` so that the final MIP never covers a customer twice. In large-scale column generation, formulating coverage as `>= 1` restricts the dual prices to $\pi_i \ge 0$, avoiding negative dual oscillations; if the final MIP selects overlapping routes, duplicate visits can be removed by post-processing whenever distances satisfy the triangle inequality.
|
|
4150
|
+
- **Heuristic Pricing vs. Exact ESPPRC**: Because `findImprovingRoute` uses a fast greedy heuristic (cheapest insertion + 2-opt) instead of an exact Elementary Shortest Path Problem with Resource Constraints (ESPPRC) solver, returning `null` means no further improving columns were found by the heuristic rather than proving global LP optimality.
|
|
4151
|
+
- **Price-and-Branch (`finalizeAsMIP: true`)**: Solving the Restricted Master Problem as a MIP over the columns generated at the root node is known as the *Restricted Master Heuristic* (Price-and-Branch). It produces near-optimal integer fleets quickly without requiring full Branch-and-Price tree search.
|
|
3894
4152
|
|
|
3895
4153
|
---
|
|
3896
4154
|
|
|
@@ -3898,7 +4156,7 @@ runCVRP();
|
|
|
3898
4156
|
|
|
3899
4157
|
| Problem Class | Solver | Key APIs | Links |
|
|
3900
4158
|
| :--- | :--- | :--- | :--- |
|
|
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="
|
|
4159
|
+
| 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">Decomposition Guide</a></li></ul> |
|
|
3902
4160
|
|
|
3903
4161
|
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
4162
|
|
|
@@ -3925,7 +4183,7 @@ We consider **5 cities** on a 2D Euclidean coordinate grid:
|
|
|
3925
4183
|
## Implementation in WaSHI
|
|
3926
4184
|
|
|
3927
4185
|
```typescript
|
|
3928
|
-
import { Model, expr, minimize
|
|
4186
|
+
import { Model, expr, minimize } from "@ohtaman/washi";
|
|
3929
4187
|
|
|
3930
4188
|
async function runTSP() {
|
|
3931
4189
|
const cities = [
|
|
@@ -3946,39 +4204,26 @@ async function runTSP() {
|
|
|
3946
4204
|
// 1. Master Problem: Degree assignment relaxation
|
|
3947
4205
|
const master = new Model("TSP_BendersMaster");
|
|
3948
4206
|
const x = {};
|
|
4207
|
+
const distMatrix = {};
|
|
3949
4208
|
|
|
3950
4209
|
for (let i = 0; i < n; i++) {
|
|
3951
4210
|
x[i] = {};
|
|
4211
|
+
distMatrix[i] = {};
|
|
3952
4212
|
for (let j = 0; j < n; j++) {
|
|
3953
4213
|
if (i !== j) {
|
|
3954
4214
|
x[i][j] = master.binary(`x_${i}_${j}`);
|
|
4215
|
+
distMatrix[i][j] = dist(i, j);
|
|
3955
4216
|
}
|
|
3956
4217
|
}
|
|
3957
4218
|
}
|
|
3958
4219
|
|
|
3959
4220
|
// Objective: Minimize total tour distance
|
|
3960
|
-
|
|
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));
|
|
4221
|
+
master.objective(minimize(expr`dot(${distMatrix}, ${x})`));
|
|
3969
4222
|
|
|
3970
4223
|
// Each city must have 1 outgoing and 1 incoming edge
|
|
3971
4224
|
for (let i = 0; i < n; i++) {
|
|
3972
|
-
|
|
3973
|
-
|
|
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}`);
|
|
4225
|
+
master.constraint(expr`sum(${x[i]}) == 1`, `out_${i}`);
|
|
4226
|
+
master.constraint(expr`sum(${n}, ${(j) => x[j][i]}) == 1`, `in_${i}`);
|
|
3982
4227
|
}
|
|
3983
4228
|
|
|
3984
4229
|
console.log("Solving TSP with Benders lazy subtour elimination...");
|
|
@@ -3991,7 +4236,7 @@ async function runTSP() {
|
|
|
3991
4236
|
const succ = {};
|
|
3992
4237
|
for (let i = 0; i < n; i++) {
|
|
3993
4238
|
for (let j = 0; j < n; j++) {
|
|
3994
|
-
if (i !== j &&
|
|
4239
|
+
if (i !== j && x[i][j].value === 1) {
|
|
3995
4240
|
succ[i] = j;
|
|
3996
4241
|
}
|
|
3997
4242
|
}
|
|
@@ -4013,27 +4258,22 @@ async function runTSP() {
|
|
|
4013
4258
|
}
|
|
4014
4259
|
}
|
|
4015
4260
|
|
|
4016
|
-
// If more than 1 cycle, subtours exist: inject Benders feasibility
|
|
4261
|
+
// If more than 1 cycle, subtours exist: inject Benders feasibility cuts for all cycles
|
|
4017
4262
|
if (cycles.length > 1) {
|
|
4018
|
-
|
|
4019
|
-
|
|
4020
|
-
|
|
4021
|
-
|
|
4022
|
-
|
|
4023
|
-
|
|
4024
|
-
|
|
4025
|
-
|
|
4026
|
-
|
|
4027
|
-
|
|
4028
|
-
}
|
|
4029
|
-
|
|
4030
|
-
|
|
4031
|
-
|
|
4032
|
-
return {
|
|
4033
|
-
kind: "feasibility",
|
|
4034
|
-
constraint: expr`${cutLhs} >= 1`,
|
|
4035
|
-
name: `subtour_cut_${cutCounter}`,
|
|
4036
|
-
};
|
|
4263
|
+
return cycles.map((cycle) => {
|
|
4264
|
+
const S = new Set(cycle);
|
|
4265
|
+
cutCounter++;
|
|
4266
|
+
console.log(`Subtour detected: [${cycle.map(k => cities[k].name).join(" -> ")}]. Adding Benders cut #${cutCounter}.`);
|
|
4267
|
+
|
|
4268
|
+
const outCities = Array.from({ length: n }, (_, j) => j).filter((j) => !S.has(j));
|
|
4269
|
+
const cutEdges = cycle.flatMap((i) => outCities.map((j) => x[i][j]));
|
|
4270
|
+
|
|
4271
|
+
return {
|
|
4272
|
+
kind: "feasibility",
|
|
4273
|
+
constraint: expr`sum(${cutEdges}) >= 1`,
|
|
4274
|
+
name: `subtour_cut_${cutCounter}`,
|
|
4275
|
+
};
|
|
4276
|
+
});
|
|
4037
4277
|
}
|
|
4038
4278
|
|
|
4039
4279
|
return null; // Single connected tour found
|
|
@@ -4048,7 +4288,7 @@ async function runTSP() {
|
|
|
4048
4288
|
console.log("\nOptimal tour edges:");
|
|
4049
4289
|
for (let i = 0; i < n; i++) {
|
|
4050
4290
|
for (let j = 0; j < n; j++) {
|
|
4051
|
-
if (i !== j &&
|
|
4291
|
+
if (i !== j && result.values[`x_${i}_${j}`] === 1) {
|
|
4052
4292
|
console.log(` ${cities[i].name} -> ${cities[j].name} (distance: ${dist(i, j).toFixed(3)})`);
|
|
4053
4293
|
}
|
|
4054
4294
|
}
|
|
@@ -4061,5 +4301,7 @@ runTSP();
|
|
|
4061
4301
|
## Notes
|
|
4062
4302
|
|
|
4063
4303
|
- **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
|
|
4304
|
+
- **Multi-Cut Separation**: Returning an array of cuts (`cycles.map(...)`) from `subproblem` eliminates all disconnected cycles discovered in the current candidate solution simultaneously, reducing the number of master MIP re-solves.
|
|
4305
|
+
- **Two Equivalent DFJ Cut Forms**: Because every city has out-degree $\sum_{j \ne i} x_{ij} = 1$, summing over $i \in S$ gives $\sum_{i \in S, j \in S} x_{ij} + \sum_{i \in S, j \notin S} x_{ij} = |S|$. Thus the cut-set inequality $\sum_{i \in S, j \notin S} x_{ij} \ge 1$ used above and the internal-edge inequality $\sum_{i \in S, j \in S} x_{ij} \le |S| - 1$ used in [`14-traveling-salesperson-benders.ts`](https://github.com/ohtaman/washi/blob/main/examples/node/14-traveling-salesperson-benders.ts) are algebraically identical.
|
|
4306
|
+
- **Upfront 2-City Subtour Elimination**: In larger TSP instances, mutual 2-cycles ($i \leftrightarrow j$) are the most frequent initial subtours. Adding the $\binom{n}{2}$ pairwise constraints $x_{ij} + x_{ji} \le 1$ directly to the initial master problem often cuts the number of Benders iterations substantially.
|
|
4065
4307
|
- **Termination Condition**: Returning `null` from `subproblem` indicates that no violated subtour constraints remain.
|