@ohtaman/washi 0.5.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 -386
- package/README.md +104 -387
- package/README.zh-CN.md +103 -386
- package/THIRD_PARTY_LICENSES.md +5 -4
- package/dist/browser/_washi-node-only-BvRk9kiK.js +0 -0
- package/dist/browser/browser-runtime-TLCY0hkc.js +1793 -0
- package/dist/browser/highs-worker.js +1 -1
- package/dist/browser/highs.wasm +0 -0
- package/dist/browser/highs.wasm.LICENSE +1 -2
- package/dist/browser/loader-DewlSho8.js +643 -0
- package/dist/browser/solvers/glpk.js +3 -3
- package/dist/browser/solvers/glpk.min.js +1 -1
- package/dist/browser/solvers/z3.js +7 -1
- package/dist/browser/solvers/z3.min.js +1 -1
- package/dist/browser/washi.js +1708 -540
- package/dist/browser/washi.min.js +3 -4
- package/dist/lib/_washi-node-only-BvRk9kiK.js +0 -0
- package/dist/lib/highs-browser-runtime.js +2120 -4124
- package/dist/lib/highs-node-runtime.js +24 -1
- package/dist/lib/highs-runtime.js +2 -0
- package/dist/lib/highs-worker.js +1 -1
- package/dist/lib/highs.wasm +0 -0
- package/dist/lib/highs.wasm.LICENSE +1 -2
- package/dist/lib/loader-BZUQNvh4.js +649 -0
- package/dist/lib/{registry-BEfvpZW3.js → registry-D7TZ3Wh_.js} +142 -59
- package/dist/lib/solvers/glpk.js +4 -4
- package/dist/lib/solvers/z3.js +1 -1
- package/dist/lib/washi.js +1576 -465
- package/dist/types/decomposition/benders.d.ts +32 -0
- package/dist/types/decomposition/benders.d.ts.map +1 -0
- package/dist/types/decomposition/column-generation.d.ts +34 -0
- package/dist/types/decomposition/column-generation.d.ts.map +1 -0
- package/dist/types/decomposition/index.d.ts +4 -0
- package/dist/types/decomposition/index.d.ts.map +1 -0
- package/dist/types/decomposition/types.d.ts +230 -0
- package/dist/types/decomposition/types.d.ts.map +1 -0
- package/dist/types/index.d.ts +4 -3
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/modeler/boolean-expr.d.ts.map +1 -1
- package/dist/types/modeler/constraint.d.ts +11 -0
- package/dist/types/modeler/constraint.d.ts.map +1 -1
- package/dist/types/modeler/expression.d.ts +57 -26
- package/dist/types/modeler/expression.d.ts.map +1 -1
- package/dist/types/modeler/index.d.ts +4 -3
- package/dist/types/modeler/index.d.ts.map +1 -1
- package/dist/types/modeler/linearizer.d.ts +15 -0
- package/dist/types/modeler/linearizer.d.ts.map +1 -0
- package/dist/types/modeler/lp-writer.d.ts +3 -1
- package/dist/types/modeler/lp-writer.d.ts.map +1 -1
- package/dist/types/modeler/model.d.ts +159 -8
- 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/browser-runtime.d.ts +2 -1
- package/dist/types/modeler/solvers/highs/browser-runtime.d.ts.map +1 -1
- package/dist/types/modeler/solvers/highs/driver.d.ts +232 -0
- package/dist/types/modeler/solvers/highs/driver.d.ts.map +1 -0
- package/dist/types/modeler/solvers/highs/index.d.ts +1 -0
- package/dist/types/modeler/solvers/highs/index.d.ts.map +1 -1
- package/dist/types/modeler/solvers/highs/loader.d.ts +4 -4
- package/dist/types/modeler/solvers/highs/loader.d.ts.map +1 -1
- package/dist/types/modeler/solvers/highs/node-runtime.d.ts +2 -1
- package/dist/types/modeler/solvers/highs/node-runtime.d.ts.map +1 -1
- package/dist/types/modeler/solvers/highs/solver.d.ts +1 -1
- package/dist/types/modeler/solvers/highs/solver.d.ts.map +1 -1
- package/dist/types/modeler/solvers/highs/worker-client.d.ts.map +1 -1
- package/dist/types/modeler/solvers/types.d.ts +4 -0
- package/dist/types/modeler/solvers/types.d.ts.map +1 -1
- package/dist/types/modeler/types.d.ts +27 -0
- package/dist/types/modeler/types.d.ts.map +1 -1
- package/docs/examples/diet-sensitivity.md +15 -14
- package/docs/examples/ecommerce-coupons.md +15 -22
- package/docs/examples/index.md +18 -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 +157 -0
- package/docs/examples/unsat-core.md +15 -5
- package/docs/examples/vehicle-routing.md +208 -0
- package/docs/getting-started/ai-agents.md +33 -18
- 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 +47 -41
- package/docs/guide/decomposition.md +267 -0
- 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 +28 -9
- package/docs/guide/variables.md +33 -38
- package/docs/ja/getting-started/ai-agents.md +32 -18
- package/docs/solvers/custom-solver.md +1 -1
- package/docs/solvers/highs.md +48 -5
- package/docs/solvers/index.md +4 -4
- package/docs/solvers/z3.md +3 -0
- package/docs/zh/getting-started/ai-agents.md +32 -18
- package/llms-full.txt +1536 -478
- package/llms.txt +28 -22
- package/package.json +19 -16
- package/skills/washi/SKILL.md +21 -17
- package/dist/browser/browser-runtime-D13gHOT5.js +0 -4189
- package/dist/browser/loader-CtaDHef_.js +0 -273
- package/dist/lib/loader-DmNC_UNk.js +0 -279
package/llms-full.txt
CHANGED
|
@@ -3,7 +3,10 @@
|
|
|
3
3
|
> **Scope Note**: This document is the **consumer modeling quick-reference** for AI coding agents and developers building optimization or satisfiability models with `@ohtaman/washi`.
|
|
4
4
|
> - **Portable Agent Skill (`SKILL.md`)**: For an actionable end-to-end modeling, `toLpString()` inspection, `validateSolution()` verification, and Z3 `unsatCore` conflict-diagnosis workflow skill (compatible with Claude Code, Antigravity, Cursor, Codex, and other `SKILL.md`-aware agents), use [`skills/washi/SKILL.md`](https://github.com/ohtaman/washi/blob/main/skills/washi/SKILL.md) (also shipped in the npm package at `node_modules/@ohtaman/washi/skills/washi/SKILL.md`):
|
|
5
5
|
> ```bash
|
|
6
|
-
> #
|
|
6
|
+
> # Install directly via the skills CLI (skills.sh / vercel-labs/skills)
|
|
7
|
+
> npx skills add ohtaman/washi
|
|
8
|
+
>
|
|
9
|
+
> # Or link/copy the packaged skill into your project's agent skills directory
|
|
7
10
|
> mkdir -p .agents/skills/washi
|
|
8
11
|
> cp node_modules/@ohtaman/washi/skills/washi/SKILL.md .agents/skills/washi/SKILL.md
|
|
9
12
|
> ```
|
|
@@ -54,7 +57,7 @@ import "@ohtaman/washi/solvers/glpk";
|
|
|
54
57
|
1. **Bundled WASM by default (no implicit CDN fallback)**:
|
|
55
58
|
- In Node.js and modern bundlers, WebAssembly assets resolve relative to the module automatically.
|
|
56
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.
|
|
57
|
-
- 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).
|
|
58
61
|
2. **Z3 requires a Secure Context, Cross-Origin Isolation, and CORS in browsers**:
|
|
59
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:
|
|
60
63
|
- `Cross-Origin-Opener-Policy: same-origin` (on the document)
|
|
@@ -63,7 +66,7 @@ import "@ohtaman/washi/solvers/glpk";
|
|
|
63
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.
|
|
64
67
|
3. **Web Workers & Cancellation (`AbortSignal`)**:
|
|
65
68
|
- Pre-solve cancellation (`signal.aborted` before execution starts) is supported across all solvers.
|
|
66
|
-
- 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).
|
|
67
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.
|
|
68
71
|
|
|
69
72
|
---
|
|
@@ -79,6 +82,7 @@ const m = new Model("ExampleModel");
|
|
|
79
82
|
|
|
80
83
|
// Scalar variables
|
|
81
84
|
const x = m.continuous("x", { lb: 0, ub: 100 });
|
|
85
|
+
const y = m.continuous("y", { lb: 0, ub: 100 });
|
|
82
86
|
const n = m.integer("n", { lb: 0, ub: 10 });
|
|
83
87
|
const b = m.binary("b");
|
|
84
88
|
const flag = m.bool("flag"); // Propositional boolean (for Z3 / logical constraints)
|
|
@@ -100,12 +104,12 @@ const mixed = m.binary(["A", "B"] as const, 3); // Record<"A" | "B", Var[]>
|
|
|
100
104
|
### Step 2: Build Expressions (`expr`, `sum`, `dot`)
|
|
101
105
|
|
|
102
106
|
- **Unified `expr` template tag**:
|
|
103
|
-
- Linear expression (`LinExpr`): `` expr`30 * ${x} + 50 * ${n} - 10` ``
|
|
104
|
-
- Quadratic expression (`QuadExpr`): `` expr`${x}^2 + 2 * ${x} * ${
|
|
105
|
-
- Linear constraint (`Constraint`): `` expr`${x} + 2 * ${n} <= 40` `` (supports `<=`, `>=`, `==`, and `=`).
|
|
106
|
-
- **Aggregations**:
|
|
107
|
-
- `sum(
|
|
108
|
-
- `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.
|
|
109
113
|
- **Fluent methods**:
|
|
110
114
|
- Arithmetic (on `LinExpr` and `QuadExpr`; `Var` supports `.add()`, `.sub()`, `.mul()`, `.neg()`): `.add(other)`, `.sub(other)`, `.mul(other)`, `.div(scalar)`, `.neg()`
|
|
111
115
|
- Comparisons (on `Var` and `LinExpr`): `.le(rhs, name?)`, `.ge(rhs, name?)`, `.eq(rhs, name?)`
|
|
@@ -119,8 +123,8 @@ Constructing an `expr` or `.le()` object does **not** attach it to the model unt
|
|
|
119
123
|
// Set objective using maximize() or minimize()
|
|
120
124
|
m.objective(maximize(expr`30 * ${x} + 50 * ${n}`));
|
|
121
125
|
|
|
122
|
-
// Register constraints on the model
|
|
123
|
-
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");
|
|
124
128
|
|
|
125
129
|
// Indicator constraint (MIP Big-M reformulation: b === 1 => x <= 50)
|
|
126
130
|
m.indicator(b, expr`${x} <= 50`, { activeValue: 1, name: "cap_when_b" });
|
|
@@ -132,9 +136,10 @@ m.indicator(b, expr`${x} <= 50`, { activeValue: 1, name: "cap_when_b" });
|
|
|
132
136
|
- Returns `SolverStatus` (`"Optimal"`, `"Feasible"`, `"Infeasible"`, `"Unbounded"`, `"TimeLimit"`, `"IterationLimit"`, `"Error"`, `"Unknown"`, `"NotSolved"`, `"Sat"`, or `"Unsat"`). Built-in optimization solvers return the optimization statuses (`"Optimal"`, `"Feasible"`, `"Infeasible"`, `"Unbounded"`, `"TimeLimit"`, `"IterationLimit"`, `"Error"`, `"Unknown"`, `"NotSolved"`), where a time-, iteration-, or solution-limited run (or an interrupted solve) that found a valid primal incumbent returns `"Feasible"`; `"Sat"` and `"Unsat"` are included in `SolverStatus` for satisfiability/custom solver adapters (such as `MockSolver`).
|
|
133
137
|
- Read values via `x.value`, `expr`...`.value`, `m.getValues(x)`, `m.getValues(container)`, `m.objectiveValue`, `constr.dual`, `constr.slack`, and `x.reducedCost`.
|
|
134
138
|
- Call `m.validateSolution()` after solving to independently verify variable finiteness, variable bounds (`lb`/`ub`), integer/binary/boolean domains, linear constraint residuals, and logical constraints (`SolutionValidationReport`: `{ valid, complete, maxViolation, violations, ... }`). Note that `validateSolution()` checks primal feasibility of variables and constraints; it does not recompute or compare `m.objectiveValue`.
|
|
135
|
-
- **Satisfiability &
|
|
136
|
-
- Returns `CheckResult` (`{ status: "Sat" | "Unsat" | "Unknown", values: Record<string, number | boolean>, conflicts?: readonly string[], unsatCore?: readonly string[], runTime?: number }`), where `conflicts`
|
|
137
|
-
- For models containing
|
|
139
|
+
- **Satisfiability & Conflict Diagnosis (`await m.check(options?)`)**:
|
|
140
|
+
- Returns `CheckResult` (`{ status: "Sat" | "Unsat" | "Unknown", values: Record<string, number | boolean>, conflicts?: readonly string[], unsatCore?: readonly string[], runTime?: number }`), where `conflicts` is populated when the model is proven `"Unsat"`.
|
|
141
|
+
- For models containing linear arithmetic constraints or logical constraints (`BoolVar`, `and`, `or`, `implies`, `xor`), `m.check()` works with any solver (including the default HiGHS and GLPK via single-solve elastic slack relaxation, automatically linearizing logical constraints into 0-1 MIP formulation) and diagnoses conflicting constraints as `conflicts` (leaving `unsatCore: undefined` for semantic rigor).
|
|
142
|
+
- Exact SMT unsatisfiable-core extraction (`result.unsatCore` and `result.conflicts`) is provided when using an SMT-capable solver (`{ solver: "z3" }`). Because Z3 asserts variable bounds (`lb`/`ub`) and binary `{0, 1}` domains directly while tracking registered constraints, `unsatCore` reports a subset of registered constraint names that conflicts *together with the model's fixed variable bounds/domains* (and is not guaranteed minimal).
|
|
138
143
|
- **No concurrent mutation during async solve/check**:
|
|
139
144
|
- Mutating variables, constraints, or the objective while `await m.solve()` or `await m.check()` is pending invalidates the in-flight execution and rejects the stale promise.
|
|
140
145
|
|
|
@@ -157,10 +162,13 @@ export async function runMipExample() {
|
|
|
157
162
|
const qty = m.integer(products, { lb: 0, ub: 100 });
|
|
158
163
|
const setup = m.binary("setup");
|
|
159
164
|
|
|
160
|
-
m.objective(maximize(expr
|
|
161
|
-
m.constraint(expr
|
|
162
|
-
m.constraint(expr
|
|
163
|
-
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
|
+
});
|
|
164
172
|
|
|
165
173
|
const status = await m.solve();
|
|
166
174
|
const report = m.validateSolution();
|
|
@@ -265,24 +273,46 @@ export function verifyNegativePatterns() {
|
|
|
265
273
|
}
|
|
266
274
|
```
|
|
267
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
|
+
|
|
268
282
|
---
|
|
269
283
|
|
|
270
284
|
# Introduction & Installation
|
|
271
285
|
|
|
272
|
-
|
|
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**).
|
|
287
|
+
|
|
288
|
+
```typescript
|
|
289
|
+
import { Model, expr, maximize } from "@ohtaman/washi";
|
|
290
|
+
|
|
291
|
+
const m = new Model("production");
|
|
273
292
|
|
|
274
|
-
|
|
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 });
|
|
275
296
|
|
|
276
|
-
|
|
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");
|
|
277
301
|
|
|
278
|
-
|
|
279
|
-
|
|
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
|
+
```
|
|
280
310
|
|
|
281
311
|
---
|
|
282
312
|
|
|
283
313
|
## 📦 Installation
|
|
284
314
|
|
|
285
|
-
|
|
315
|
+
The default **HiGHS** solver (LP / MIP / Convex QP) is bundled and works out of the box:
|
|
286
316
|
|
|
287
317
|
```bash [npm]
|
|
288
318
|
npm install @ohtaman/washi
|
|
@@ -300,96 +330,54 @@ yarn add @ohtaman/washi
|
|
|
300
330
|
bun add @ohtaman/washi
|
|
301
331
|
```
|
|
302
332
|
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
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**:
|
|
306
334
|
|
|
307
335
|
```bash
|
|
308
|
-
# For SMT logic and Unsat Core conflict diagnosis
|
|
309
|
-
npm install
|
|
310
|
-
|
|
311
|
-
# For GLPK solver (GPL-3.0)
|
|
312
|
-
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)
|
|
313
338
|
```
|
|
314
339
|
|
|
315
340
|
---
|
|
316
341
|
|
|
317
|
-
##
|
|
318
|
-
|
|
319
|
-
Here is a step-by-step example of solving a production allocation problem.
|
|
320
|
-
|
|
321
|
-
### 1. Import the Core Classes
|
|
322
|
-
|
|
323
|
-
```typescript
|
|
324
|
-
import { Model, expr, maximize } from "@ohtaman/washi";
|
|
325
|
-
```
|
|
326
|
-
|
|
327
|
-
### 2. Instantiate a Model
|
|
328
|
-
|
|
329
|
-
```typescript
|
|
330
|
-
const model = new Model("WoodworkingProfit");
|
|
331
|
-
```
|
|
332
|
-
|
|
333
|
-
### 3. Add Decision Variables
|
|
334
|
-
|
|
335
|
-
Variables represent the unknowns you want to determine. Factory methods (`m.continuous`, `m.integer`, `m.binary`) clearly define their domain:
|
|
342
|
+
## 💡 Why TypeScript & WebAssembly?
|
|
336
343
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
### 4. Set the Objective Function
|
|
343
|
-
|
|
344
|
-
Use the `expr` tagged template literal along with `maximize(...)` or `minimize(...)` to define the goal:
|
|
345
|
-
|
|
346
|
-
```typescript
|
|
347
|
-
// Maximize 30*x + 50*y
|
|
348
|
-
model.objective(maximize(expr`30 * ${x} + 50 * ${y}`));
|
|
349
|
-
```
|
|
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.
|
|
350
348
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
Use `model.constraint()` with the `expr` tagged template to formulate equality and inequality constraints:
|
|
354
|
-
|
|
355
|
-
```typescript
|
|
356
|
-
model.constraint(expr`${x} + 2 * ${y} <= 40`, "wood_capacity");
|
|
357
|
-
model.constraint(expr`2 * ${x} + ${y} <= 50`, "labor_capacity");
|
|
358
|
-
```
|
|
359
|
-
|
|
360
|
-
### 6. Solve and Extract Results
|
|
349
|
+
---
|
|
361
350
|
|
|
362
|
-
|
|
351
|
+
## 🔍 Core Workflow Essentials
|
|
363
352
|
|
|
364
|
-
|
|
365
|
-
const status = await model.solve();
|
|
353
|
+
Three practical habits cover the essentials shown in the snippet above:
|
|
366
354
|
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
```
|
|
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.
|
|
371
358
|
|
|
372
359
|
---
|
|
373
360
|
|
|
374
361
|
## 🧭 Next Steps
|
|
375
362
|
|
|
376
|
-
-
|
|
377
|
-
-
|
|
378
|
-
-
|
|
379
|
-
-
|
|
380
|
-
- 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
|
|
381
367
|
|
|
382
368
|
---
|
|
383
369
|
|
|
384
370
|
# Runtime Environments & Deployment
|
|
385
371
|
|
|
386
|
-
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.
|
|
387
373
|
|
|
388
374
|
---
|
|
389
375
|
|
|
390
|
-
## 🟢 Node.js
|
|
376
|
+
## 🟢 Node.js, Bun & Deno
|
|
391
377
|
|
|
392
|
-
|
|
378
|
+
### Node.js
|
|
379
|
+
|
|
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:
|
|
393
381
|
|
|
394
382
|
```typescript
|
|
395
383
|
import { Model, expr, maximize } from "@ohtaman/washi";
|
|
@@ -402,8 +390,10 @@ m.objective(maximize(expr`2 * ${x} + 3 * ${y}`));
|
|
|
402
390
|
m.constraint(expr`${x} + ${y} <= 15`, "capacity");
|
|
403
391
|
|
|
404
392
|
await m.solve();
|
|
405
|
-
|
|
406
|
-
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
|
+
}
|
|
407
397
|
```
|
|
408
398
|
|
|
409
399
|
Run directly with `tsx` or `node`:
|
|
@@ -412,11 +402,33 @@ Run directly with `tsx` or `node`:
|
|
|
412
402
|
npx tsx app.ts
|
|
413
403
|
```
|
|
414
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
|
+
|
|
415
427
|
---
|
|
416
428
|
|
|
417
|
-
## 🌐 Bundlers (Vite, Webpack, Rollup
|
|
429
|
+
## 🌐 Bundlers & Static Hosting (Vite, Next.js, Webpack, Rollup)
|
|
418
430
|
|
|
419
|
-
### Modern Bundlers
|
|
431
|
+
### Modern Bundlers & Static Deployment
|
|
420
432
|
|
|
421
433
|
When importing `@ohtaman/washi` in a browser bundler, the `browser` export condition resolves automatically:
|
|
422
434
|
|
|
@@ -428,14 +440,16 @@ export async function runOptimization() {
|
|
|
428
440
|
const x = m.continuous("x", { lb: 0, ub: 100 });
|
|
429
441
|
m.objective(maximize(x));
|
|
430
442
|
await m.solve();
|
|
431
|
-
return x.value;
|
|
443
|
+
return m.hasPrimalSolution ? x.value : null;
|
|
432
444
|
}
|
|
433
445
|
```
|
|
434
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
|
+
|
|
435
449
|
#### Background Web Worker Execution (Default)
|
|
436
|
-
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`).
|
|
437
451
|
|
|
438
|
-
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`:
|
|
439
453
|
|
|
440
454
|
```typescript
|
|
441
455
|
await m.solve({ useWorker: false });
|
|
@@ -474,7 +488,9 @@ export default function OptimizerComponent() {
|
|
|
474
488
|
const x = m.continuous("x", { lb: 0, ub: 50 });
|
|
475
489
|
m.objective(maximize(x));
|
|
476
490
|
await m.solve();
|
|
477
|
-
|
|
491
|
+
if (m.hasPrimalSolution) {
|
|
492
|
+
setVal(x.value);
|
|
493
|
+
}
|
|
478
494
|
};
|
|
479
495
|
|
|
480
496
|
return (
|
|
@@ -490,36 +506,36 @@ export default function OptimizerComponent() {
|
|
|
490
506
|
|
|
491
507
|
## 📦 Browser ESM (`<script type="module">`)
|
|
492
508
|
|
|
493
|
-
|
|
509
|
+
Without a bundler, you can import the pre-built browser ESM module directly from your own static server or an npm CDN:
|
|
494
510
|
|
|
495
511
|
```html
|
|
496
512
|
<script type="module">
|
|
497
|
-
//
|
|
498
|
-
import { Model, expr, maximize } from "
|
|
499
|
-
// Or from an npm CDN once a compatible release is published:
|
|
500
|
-
// 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";
|
|
501
515
|
// Optional solver plugins:
|
|
502
|
-
// import "
|
|
503
|
-
// 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";
|
|
504
518
|
|
|
505
519
|
const m = new Model("BrowserESM");
|
|
506
|
-
const x = m.integer("x", { lb: 0 });
|
|
507
|
-
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 });
|
|
508
522
|
|
|
509
523
|
m.objective(maximize(expr`30 * ${x} + 50 * ${y}`));
|
|
510
524
|
m.constraint(expr`${x} + 2 * ${y} <= 40`, "wood");
|
|
511
525
|
m.constraint(expr`2 * ${x} + ${y} <= 50`, "labor");
|
|
512
526
|
|
|
513
527
|
await m.solve();
|
|
514
|
-
|
|
528
|
+
if (m.hasPrimalSolution) {
|
|
529
|
+
console.log("Optimal Objective:", m.objectiveValue);
|
|
530
|
+
}
|
|
515
531
|
</script>
|
|
516
532
|
```
|
|
517
533
|
|
|
518
534
|
---
|
|
519
535
|
|
|
520
|
-
## 🟨 Classic `<script>` Tags (IIFE / Standalone
|
|
536
|
+
## 🟨 Classic `<script>` Tags (IIFE / Standalone HTML / CDN)
|
|
521
537
|
|
|
522
|
-
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:
|
|
523
539
|
|
|
524
540
|
```html
|
|
525
541
|
<!DOCTYPE html>
|
|
@@ -532,13 +548,11 @@ For static HTML pages, CodePen, or JSFiddle, load `washi.min.js` via a standard
|
|
|
532
548
|
<h1>WaSHI Browser Optimization</h1>
|
|
533
549
|
<pre id="output">Solving...</pre>
|
|
534
550
|
|
|
535
|
-
<!-- Core WaSHI + HiGHS (MIT)
|
|
536
|
-
<script src="
|
|
537
|
-
<!-- Or via CDN once a compatible release is published: -->
|
|
538
|
-
<!-- <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>
|
|
539
553
|
<!-- Optional plugins (auto-register on window.Washi): -->
|
|
540
|
-
<!-- <script src="
|
|
541
|
-
<!-- <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> -->
|
|
542
556
|
|
|
543
557
|
<script>
|
|
544
558
|
(async () => {
|
|
@@ -553,10 +567,12 @@ For static HTML pages, CodePen, or JSFiddle, load `washi.min.js` via a standard
|
|
|
553
567
|
|
|
554
568
|
await m.solve();
|
|
555
569
|
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
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
|
+
}
|
|
560
576
|
})();
|
|
561
577
|
</script>
|
|
562
578
|
</body>
|
|
@@ -564,7 +580,7 @@ For static HTML pages, CodePen, or JSFiddle, load `washi.min.js` via a standard
|
|
|
564
580
|
```
|
|
565
581
|
|
|
566
582
|
> [!NOTE]
|
|
567
|
-
> 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.
|
|
568
584
|
|
|
569
585
|
---
|
|
570
586
|
|
|
@@ -578,83 +594,81 @@ WaSHI never phones home or falls back to public CDNs. To self-host in an air-gap
|
|
|
578
594
|
- Optional Z3: `solvers/z3.js` / `solvers/z3.min.js`, `solvers/z3-built.js`, `solvers/z3-built.wasm`
|
|
579
595
|
2. **Content Security Policy (CSP) Headers**:
|
|
580
596
|
- Allow WebAssembly compilation (`'wasm-unsafe-eval'` in `script-src`).
|
|
581
|
-
- 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:;`.
|
|
582
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 })`.
|
|
583
599
|
3. **Cross-Origin Isolation (Z3 Only)**:
|
|
584
600
|
- HiGHS and GLPK run in standard browser contexts without special headers.
|
|
585
|
-
- Z3 WASM uses pthreads + `SharedArrayBuffer` and requires `Cross-Origin-Opener-Policy: same-origin` and `Cross-Origin-Embedder-Policy: require-corp` (
|
|
586
|
-
|
|
587
|
-
---
|
|
588
|
-
|
|
589
|
-
## ⚡ Bun & Deno
|
|
590
|
-
|
|
591
|
-
### Bun
|
|
592
|
-
|
|
593
|
-
```bash
|
|
594
|
-
bun add @ohtaman/washi
|
|
595
|
-
bun run app.ts
|
|
596
|
-
```
|
|
597
|
-
|
|
598
|
-
### Deno
|
|
599
|
-
|
|
600
|
-
In Deno (v1.30+ with `npm:` specifiers):
|
|
601
|
-
|
|
602
|
-
```typescript
|
|
603
|
-
import { Model, maximize } from "npm:@ohtaman/washi";
|
|
604
|
-
|
|
605
|
-
const m = new Model();
|
|
606
|
-
const x = m.continuous("x", { lb: 0, ub: 10 });
|
|
607
|
-
m.objective(maximize(x));
|
|
608
|
-
await m.solve();
|
|
609
|
-
console.log("x =", x.value);
|
|
610
|
-
```
|
|
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.
|
|
611
602
|
|
|
612
603
|
---
|
|
613
604
|
|
|
614
605
|
# Cross-Origin Isolation (Z3 & Browsers)
|
|
615
606
|
|
|
616
|
-
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.
|
|
617
611
|
|
|
618
612
|
---
|
|
619
613
|
|
|
620
614
|
## Why is Cross-Origin Isolation Required?
|
|
621
615
|
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
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`).
|
|
625
617
|
|
|
626
618
|
---
|
|
627
619
|
|
|
628
620
|
## Required HTTP Response Headers
|
|
629
621
|
|
|
630
|
-
|
|
622
|
+
Serve the top-level HTML document with:
|
|
631
623
|
|
|
632
624
|
```http
|
|
633
625
|
Cross-Origin-Opener-Policy: same-origin
|
|
634
626
|
Cross-Origin-Embedder-Policy: require-corp
|
|
635
627
|
```
|
|
636
628
|
|
|
637
|
-
|
|
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).
|
|
638
630
|
|
|
639
|
-
|
|
640
|
-
Cross-Origin-
|
|
641
|
-
```
|
|
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.
|
|
642
633
|
|
|
643
634
|
---
|
|
644
635
|
|
|
645
|
-
##
|
|
636
|
+
## Setup by Environment
|
|
646
637
|
|
|
647
|
-
### 1.
|
|
638
|
+
### 1. Vite (`vite.config.ts`)
|
|
648
639
|
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
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
|
+
},
|
|
654
658
|
});
|
|
655
659
|
```
|
|
656
660
|
|
|
657
|
-
### 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`)
|
|
658
672
|
|
|
659
673
|
```javascript
|
|
660
674
|
/** @type {import('next').NextConfig} */
|
|
@@ -675,35 +689,40 @@ const nextConfig = {
|
|
|
675
689
|
module.exports = nextConfig;
|
|
676
690
|
```
|
|
677
691
|
|
|
678
|
-
###
|
|
692
|
+
### 4. Custom Node.js / Express Server
|
|
693
|
+
|
|
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
|
+
```
|
|
679
701
|
|
|
680
|
-
|
|
702
|
+
### 5. Static Hosting without Header Control (e.g., GitHub Pages)
|
|
681
703
|
|
|
682
|
-
|
|
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:
|
|
683
705
|
|
|
684
|
-
1. Place `coi-serviceworker.js`
|
|
685
|
-
2.
|
|
706
|
+
1. Place `coi-serviceworker.js` at your site root.
|
|
707
|
+
2. Load it in your HTML `<head>`:
|
|
686
708
|
|
|
687
709
|
```html
|
|
688
710
|
<script src="/coi-serviceworker.js"></script>
|
|
689
711
|
```
|
|
690
712
|
|
|
691
|
-
|
|
713
|
+
On the first visit, the Service Worker registers and reloads the page once to establish `window.crossOriginIsolated === true`.
|
|
692
714
|
|
|
693
715
|
---
|
|
694
716
|
|
|
695
|
-
##
|
|
717
|
+
## Runtime Check in WaSHI
|
|
696
718
|
|
|
697
|
-
WaSHI's Z3
|
|
698
|
-
|
|
699
|
-
> [!TIP]
|
|
700
|
-
> 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.
|
|
701
720
|
|
|
702
721
|
---
|
|
703
722
|
|
|
704
723
|
# Variables & Domains
|
|
705
724
|
|
|
706
|
-
Variables represent the mathematical decision variables in your optimization model.
|
|
725
|
+
Variables represent the mathematical decision variables in your optimization or logical model.
|
|
707
726
|
WaSHI provides domain-specific factory methods on the `Model` instance: `m.continuous()`, `m.integer()`, `m.binary()`, and `m.bool()`.
|
|
708
727
|
|
|
709
728
|
---
|
|
@@ -711,7 +730,7 @@ WaSHI provides domain-specific factory methods on the `Model` instance: `m.conti
|
|
|
711
730
|
## Decision Variable Types
|
|
712
731
|
|
|
713
732
|
### 1. Continuous Variables (`m.continuous`)
|
|
714
|
-
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:
|
|
715
734
|
|
|
716
735
|
```typescript
|
|
717
736
|
import { Model } from "@ohtaman/washi";
|
|
@@ -723,7 +742,7 @@ const fuel = m.continuous("fuel", { lb: 0, ub: 1000 });
|
|
|
723
742
|
```
|
|
724
743
|
|
|
725
744
|
### 2. Integer Variables (`m.integer`)
|
|
726
|
-
Restricted to
|
|
745
|
+
Restricted to integer values within `[lb, ub]`. Used for discrete counts such as machines, vehicles, or headcount:
|
|
727
746
|
|
|
728
747
|
```typescript
|
|
729
748
|
// Single integer variable bounded in [0, 15]
|
|
@@ -731,48 +750,43 @@ const trucks = m.integer("trucks", { lb: 0, ub: 15 });
|
|
|
731
750
|
```
|
|
732
751
|
|
|
733
752
|
### 3. Binary Variables (`m.binary`)
|
|
734
|
-
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:
|
|
735
754
|
|
|
736
755
|
```typescript
|
|
737
|
-
// Single binary variable
|
|
756
|
+
// Single binary variable (lb = 0, ub = 1 automatically)
|
|
738
757
|
const openWarehouse = m.binary("open_warehouse");
|
|
739
|
-
// lb is automatically 0, ub is automatically 1
|
|
740
758
|
```
|
|
741
759
|
|
|
742
760
|
### 4. Propositional Boolean Variables (`m.bool`)
|
|
743
|
-
Pure boolean
|
|
761
|
+
Pure boolean propositions (`true` / `false`) for logical and SMT constraints:
|
|
744
762
|
|
|
745
763
|
```typescript
|
|
746
|
-
// Single propositional boolean
|
|
747
|
-
const
|
|
748
|
-
const
|
|
764
|
+
// Single propositional boolean variables
|
|
765
|
+
const isAdmin = m.bool("isAdmin");
|
|
766
|
+
const hasMfa = m.bool("hasMfa");
|
|
749
767
|
|
|
750
768
|
// First-class boolean operators: and, or, not, implies, xor
|
|
751
|
-
m.constraint(
|
|
769
|
+
m.constraint(isAdmin.implies(hasMfa), "admin_requires_mfa");
|
|
752
770
|
```
|
|
753
771
|
|
|
754
|
-
###
|
|
755
|
-
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()`
|
|
756
773
|
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
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 |
|
|
765
781
|
|
|
766
|
-
|
|
767
|
-
m.
|
|
768
|
-
m.constraint(expr`${batch} <= 100 * ${isActive}`, "max_batch_run");
|
|
769
|
-
```
|
|
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.
|
|
770
784
|
|
|
771
785
|
---
|
|
772
786
|
|
|
773
787
|
## Multi-Dimensional & Array Creation
|
|
774
788
|
|
|
775
|
-
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.
|
|
776
790
|
|
|
777
791
|
### By Count
|
|
778
792
|
Pass a number to create an array of variables:
|
|
@@ -783,15 +797,15 @@ const flags = m.bool(3); // BoolVar[] (p_0, p_1, p_2)
|
|
|
783
797
|
```
|
|
784
798
|
|
|
785
799
|
### 1D Variable Dictionaries
|
|
786
|
-
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>`:
|
|
787
801
|
|
|
788
802
|
```typescript
|
|
789
|
-
const items = ["laptop", "phone", "tablet"];
|
|
803
|
+
const items = ["laptop", "phone", "tablet"] as const;
|
|
790
804
|
|
|
791
805
|
// Record<"laptop" | "phone" | "tablet", Var>
|
|
792
806
|
const selected = m.binary(items);
|
|
793
807
|
|
|
794
|
-
const perms = m.bool(["read", "write", "execute"]);
|
|
808
|
+
const perms = m.bool(["read", "write", "execute"] as const);
|
|
795
809
|
// perms.read, perms.write, perms.execute
|
|
796
810
|
```
|
|
797
811
|
|
|
@@ -825,16 +839,16 @@ const schedule = m.binary(factories, 7, { prefix: "open_day" });
|
|
|
825
839
|
|
|
826
840
|
| Option | Type | Default | Description |
|
|
827
841
|
| :--- | :--- | :--- | :--- |
|
|
828
|
-
| `lb` | `number` | `0` | Lower bound. Defaults to 0 (non-negative). |
|
|
842
|
+
| `lb` | `number` | `0` | Lower bound. Defaults to `0` (non-negative). |
|
|
829
843
|
| `ub` | `number` | `Infinity` (or `1` for binary) | Upper bound. |
|
|
830
844
|
| `type` | `"Continuous"` \| `"Integer"` \| `"Binary"` | Inferred from method | Underlying variable domain category. |
|
|
831
|
-
| `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`. |
|
|
832
846
|
|
|
833
847
|
---
|
|
834
848
|
|
|
835
849
|
## Primal Initial Values (Warm-Start Hints)
|
|
836
850
|
|
|
837
|
-
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 = ...`):
|
|
838
852
|
|
|
839
853
|
```typescript
|
|
840
854
|
const prevValues: Record<string, number | null> = { trucks: 8, open_warehouse: 1 };
|
|
@@ -851,15 +865,15 @@ const openWarehouse2 = m2.binary("open_warehouse", {
|
|
|
851
865
|
const extraCapacity = m2.continuous("extra_capacity", { lb: 0 }); // Partial start: no initialValue needed
|
|
852
866
|
```
|
|
853
867
|
|
|
854
|
-
- **Hint vs. Hard Constraint**: `initialValue` is
|
|
855
|
-
- **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.
|
|
856
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`.
|
|
857
871
|
|
|
858
872
|
---
|
|
859
873
|
|
|
860
874
|
## Accessing Solution Values & Reduced Costs
|
|
861
875
|
|
|
862
|
-
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()`:
|
|
863
877
|
|
|
864
878
|
```typescript
|
|
865
879
|
await m.solve();
|
|
@@ -867,7 +881,7 @@ await m.solve();
|
|
|
867
881
|
// Single variable primal value
|
|
868
882
|
console.log(`trucks: ${trucks.value}`);
|
|
869
883
|
|
|
870
|
-
// Reduced cost (LP sensitivity analysis
|
|
884
|
+
// Reduced cost (LP sensitivity analysis)
|
|
871
885
|
console.log(`fuel reduced cost: ${fuel.reducedCost}`);
|
|
872
886
|
|
|
873
887
|
// Bulk extraction for 1D or 2D collections (works for both Var and BoolVar collections)
|
|
@@ -882,11 +896,11 @@ const flowValues = m.getValues(ship);
|
|
|
882
896
|
|
|
883
897
|
## Looking Up Variables by Name
|
|
884
898
|
|
|
885
|
-
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:
|
|
886
900
|
|
|
887
901
|
```typescript
|
|
888
902
|
const v = m.getVarByName("trucks"); // Var | undefined
|
|
889
|
-
const b = m.getBoolVarByName("
|
|
903
|
+
const b = m.getBoolVarByName("isAdmin"); // BoolVar | undefined
|
|
890
904
|
console.log("Total numeric variables:", m.numVars);
|
|
891
905
|
```
|
|
892
906
|
|
|
@@ -896,7 +910,7 @@ console.log("Total numeric variables:", m.numVars);
|
|
|
896
910
|
|
|
897
911
|
Mathematical expressions are linear or quadratic combinations of decision variables and numeric coefficients.
|
|
898
912
|
|
|
899
|
-
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.
|
|
900
914
|
|
|
901
915
|
| Approach | Example | Infix readability | Type safety & IDE rename | Indexed variables (`flow[i][j]`) |
|
|
902
916
|
| :--- | :--- | :--- | :--- | :--- |
|
|
@@ -906,9 +920,9 @@ In JavaScript and TypeScript—which do not support operator overloading—WaSHI
|
|
|
906
920
|
|
|
907
921
|
---
|
|
908
922
|
|
|
909
|
-
##
|
|
923
|
+
## Tagged Template Literal (`expr`)
|
|
910
924
|
|
|
911
|
-
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.
|
|
912
926
|
|
|
913
927
|
Use `expr` for both arithmetic expressions (`LinExpr | QuadExpr`) and relational constraints (`Constraint`):
|
|
914
928
|
|
|
@@ -931,44 +945,55 @@ const balanceConstraint = expr`${x} + 2 * ${y} <= 40`;
|
|
|
931
945
|
|
|
932
946
|
### Supported Syntax in `expr`
|
|
933
947
|
- **Addition & Subtraction**: `${x} + ${y} - 5`
|
|
934
|
-
- **Scalar Multiplication**: `4 * ${x}`, `${x}
|
|
948
|
+
- **Scalar Multiplication & Division**: `4 * ${x}`, `${x} / 2`
|
|
935
949
|
- **Variable Multiplication (Quadratic terms)**: `${x} * ${y}`, `2 * ${x} * ${x}`
|
|
936
950
|
- **Powers & Exponentiation**: `${x}^2`, `${x}**2`
|
|
937
|
-
- **Unary Negation**: `-${x} + 10`
|
|
951
|
+
- **Unary Plus & Negation**: `-${x} + 10`, `+${x}`
|
|
938
952
|
- **Parentheses**: `2 * (${x} + ${y})`
|
|
939
|
-
- **
|
|
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.
|
|
940
956
|
|
|
941
957
|
> [!NOTE]
|
|
942
958
|
> For relational comparisons (`<=`, `>=`, `==`), `expr` (e.g., ``expr`${x} + 2 * ${y} <= 40` ``) returns a first-class `Constraint`.
|
|
943
|
-
> 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.
|
|
944
960
|
|
|
945
961
|
---
|
|
946
962
|
|
|
947
|
-
##
|
|
963
|
+
## High-Performance Summation (`sum`)
|
|
948
964
|
|
|
949
|
-
|
|
965
|
+
Sum 1D or 2D `Array`, `Iterable`, or `Record` containers directly inside `` expr`sum(...)` `` or via the standalone `sum()` helper:
|
|
950
966
|
|
|
951
967
|
```typescript
|
|
952
|
-
import { sum } from "@ohtaman/washi";
|
|
968
|
+
import { expr, sum } from "@ohtaman/washi";
|
|
953
969
|
|
|
954
|
-
const items = ["A", "B", "C"];
|
|
970
|
+
const items = ["A", "B", "C"] as const;
|
|
955
971
|
const weights = { A: 10, B: 20, C: 30 };
|
|
956
972
|
const x = m.binary(items);
|
|
957
973
|
|
|
958
|
-
// 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):
|
|
959
981
|
const totalWeight = sum(items, (item) => x[item].mul(weights[item]));
|
|
960
982
|
|
|
961
|
-
//
|
|
962
|
-
const count = sum(
|
|
983
|
+
// 4. Standalone 1D/2D Record or Array summation:
|
|
984
|
+
const count = sum(x);
|
|
963
985
|
```
|
|
964
986
|
|
|
965
|
-
`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.
|
|
966
991
|
|
|
967
992
|
---
|
|
968
993
|
|
|
969
|
-
##
|
|
994
|
+
## Dot Product (`dot`)
|
|
970
995
|
|
|
971
|
-
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)`).
|
|
972
997
|
|
|
973
998
|
Supported 1D and 2D container shapes:
|
|
974
999
|
- **1D Arrays**: `number[]` $\times$ `(Var | LinExpr)[]`
|
|
@@ -981,41 +1006,74 @@ Mismatched array lengths, missing dictionary keys, or non-finite/non-linear leav
|
|
|
981
1006
|
|
|
982
1007
|
### 1D Vector Dot Product
|
|
983
1008
|
```typescript
|
|
984
|
-
import { dot } from "@ohtaman/washi";
|
|
1009
|
+
import { expr, dot } from "@ohtaman/washi";
|
|
985
1010
|
|
|
986
1011
|
const profits = { desk: 30, chair: 50 };
|
|
987
|
-
const vars = m.integer(["desk", "chair"]);
|
|
1012
|
+
const vars = m.integer(["desk", "chair"] as const);
|
|
988
1013
|
|
|
989
|
-
// Computes 30 * vars.desk + 50 * vars.chair
|
|
990
|
-
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})`;
|
|
991
1016
|
```
|
|
992
1017
|
|
|
993
|
-
### 2D Matrix
|
|
1018
|
+
### 2D Matrix Inner Product (Frobenius Inner Product)
|
|
994
1019
|
Works directly with 2D arrays (`m.continuous(rows, cols)`), nested dictionaries (`m.continuous(rowKeys, colKeys)`), or mixed 2D containers:
|
|
995
1020
|
|
|
996
1021
|
```typescript
|
|
997
|
-
const factories = ["Tokyo", "Osaka"];
|
|
998
|
-
const stores = ["StoreA", "StoreB"];
|
|
1022
|
+
const factories = ["Tokyo", "Osaka"] as const;
|
|
1023
|
+
const stores = ["StoreA", "StoreB"] as const;
|
|
999
1024
|
|
|
1000
1025
|
const costs = {
|
|
1001
1026
|
Tokyo: { StoreA: 4, StoreB: 7 },
|
|
1002
1027
|
Osaka: { StoreA: 8, StoreB: 3 },
|
|
1003
1028
|
};
|
|
1004
1029
|
|
|
1005
|
-
const ship = m.continuous(factories, stores);
|
|
1030
|
+
const ship = m.continuous(factories, stores, { lb: 0 });
|
|
1031
|
+
|
|
1032
|
+
// Total shipping cost across all routes (equivalent to expr`sum(${costs} * ${ship})`):
|
|
1033
|
+
const totalShippingCost = expr`dot(${costs}, ${ship})`;
|
|
1034
|
+
```
|
|
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
|
+
|
|
1039
|
+
---
|
|
1040
|
+
|
|
1041
|
+
## Composing Aggregations in `expr`
|
|
1042
|
+
|
|
1043
|
+
### Separating Operators and Data
|
|
1006
1044
|
|
|
1007
|
-
|
|
1008
|
-
|
|
1045
|
+
Write mathematical operators, `sum(...)`, `dot(..., ...)`, and comparison symbols (`<=`, `>=`, `==`) in the template string, and pass variables, scalars, and containers (`Array` / `Record`) via `${...}`:
|
|
1046
|
+
|
|
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`;
|
|
1009
1055
|
```
|
|
1010
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
|
+
|
|
1011
1069
|
---
|
|
1012
1070
|
|
|
1013
|
-
##
|
|
1071
|
+
## Quadratic Expressions (`QuadExpr` & `expr`)
|
|
1014
1072
|
|
|
1015
|
-
WaSHI
|
|
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()`.
|
|
1016
1074
|
|
|
1017
1075
|
### 1. Using `expr` (Recommended)
|
|
1018
|
-
|
|
1076
|
+
Write quadratic terms with `*`, `^2`, or `**2`:
|
|
1019
1077
|
|
|
1020
1078
|
```typescript
|
|
1021
1079
|
import { Model, expr, minimize } from "@ohtaman/washi";
|
|
@@ -1032,7 +1090,7 @@ m.constraint(expr`${x} + ${y} >= 4`);
|
|
|
1032
1090
|
```
|
|
1033
1091
|
|
|
1034
1092
|
### 2. Method Chaining with `.mul()` or `QuadExpr`
|
|
1035
|
-
|
|
1093
|
+
You can also construct quadratic expressions programmatically:
|
|
1036
1094
|
|
|
1037
1095
|
```typescript
|
|
1038
1096
|
import { Model, QuadExpr, minimize } from "@ohtaman/washi";
|
|
@@ -1041,12 +1099,11 @@ const m = new Model();
|
|
|
1041
1099
|
const x = m.continuous("x", { lb: 0 });
|
|
1042
1100
|
const y = m.continuous("y", { lb: 0 });
|
|
1043
1101
|
|
|
1044
|
-
// 1. Construct quadratic terms with .mul()
|
|
1045
|
-
// Represents x * y + 2 * x^2
|
|
1102
|
+
// 1. Construct quadratic terms with .mul() (x * y + 2 * x^2)
|
|
1046
1103
|
const quad = x.mul(y).add(x.mul(x).mul(2));
|
|
1047
1104
|
|
|
1048
|
-
// 2. Build portfolio
|
|
1049
|
-
const assets = ["Tech", "Bonds"];
|
|
1105
|
+
// 2. Build portfolio variance objective: w^T * Sigma * w
|
|
1106
|
+
const assets = ["Tech", "Bonds"] as const;
|
|
1050
1107
|
const w = m.continuous(assets, { lb: 0, ub: 1 });
|
|
1051
1108
|
|
|
1052
1109
|
let risk = new QuadExpr();
|
|
@@ -1057,13 +1114,22 @@ risk = risk.add(w.Bonds.mul(w.Bonds).mul(0.01));
|
|
|
1057
1114
|
m.objective(minimize(risk));
|
|
1058
1115
|
```
|
|
1059
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
|
+
|
|
1060
1120
|
---
|
|
1061
1121
|
|
|
1062
|
-
##
|
|
1122
|
+
## Evaluating Expressions After Solving (`.value`)
|
|
1063
1123
|
|
|
1064
|
-
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:
|
|
1065
1125
|
|
|
1066
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
|
+
|
|
1067
1133
|
const revenue = expr`30 * ${x} + 50 * ${y}`;
|
|
1068
1134
|
const laborUsed = expr`2 * ${x} + ${y}`;
|
|
1069
1135
|
|
|
@@ -1081,7 +1147,7 @@ If the model has not been solved (or any variable in the expression has `value =
|
|
|
1081
1147
|
|
|
1082
1148
|
---
|
|
1083
1149
|
|
|
1084
|
-
##
|
|
1150
|
+
## Fluent Arithmetic & Relational Methods
|
|
1085
1151
|
|
|
1086
1152
|
In addition to `expr`, `Var`, `LinExpr`, and `QuadExpr` provide immutable fluent methods for programmatic expression building:
|
|
1087
1153
|
|
|
@@ -1101,17 +1167,17 @@ In addition to `expr`, `Var`, `LinExpr`, and `QuadExpr` provide immutable fluent
|
|
|
1101
1167
|
# Constraints (`m.constraint` & `m.indicator`)
|
|
1102
1168
|
|
|
1103
1169
|
Constraints restrict the feasible solution space of your mathematical or logical model.
|
|
1104
|
-
In WaSHI, both linear arithmetic constraints and propositional
|
|
1170
|
+
In WaSHI, both linear arithmetic constraints and propositional logic formulas are registered using **`m.constraint()`**:
|
|
1105
1171
|
|
|
1106
1172
|
```typescript
|
|
1107
|
-
m.constraint(expr`${
|
|
1173
|
+
m.constraint(expr`${x} + 2 * ${y} <= 40`, "wood_limit");
|
|
1108
1174
|
```
|
|
1109
1175
|
|
|
1110
1176
|
---
|
|
1111
1177
|
|
|
1112
|
-
##
|
|
1178
|
+
## Algebraic Linear Constraints (`expr`)
|
|
1113
1179
|
|
|
1114
|
-
The `expr` tagged template literal
|
|
1180
|
+
The `expr` tagged template literal allows writing equations and inequalities with standard mathematical notation:
|
|
1115
1181
|
|
|
1116
1182
|
```typescript
|
|
1117
1183
|
import { Model, expr } from "@ohtaman/washi";
|
|
@@ -1130,13 +1196,15 @@ m.constraint(expr`3 * ${x} + ${y} >= 20`, "min_demand");
|
|
|
1130
1196
|
m.constraint(expr`${x} + ${y} == 30`, "exact_quota");
|
|
1131
1197
|
```
|
|
1132
1198
|
|
|
1199
|
+
`m.constraint()` returns the registered `Constraint` (or `BoolExpr` for logical formulas), or an array when passed a batch array.
|
|
1200
|
+
|
|
1133
1201
|
### Supported Comparison Operators
|
|
1134
1202
|
- `<=`: Upper bound constraint
|
|
1135
1203
|
- `>=`: Lower bound constraint
|
|
1136
1204
|
- `==` or `=`: Exact equality constraint
|
|
1137
1205
|
|
|
1138
1206
|
### Batch Registration
|
|
1139
|
-
|
|
1207
|
+
Pass an array of constraints to `m.constraint([...])` to register them transactionally in a single call:
|
|
1140
1208
|
|
|
1141
1209
|
```typescript
|
|
1142
1210
|
m.constraint([expr`${x} <= 10`, expr`${y} <= 20`]);
|
|
@@ -1144,50 +1212,51 @@ m.constraint([expr`${x} <= 10`, expr`${y} <= 20`]);
|
|
|
1144
1212
|
|
|
1145
1213
|
---
|
|
1146
1214
|
|
|
1147
|
-
##
|
|
1215
|
+
## Naming Constraints
|
|
1148
1216
|
|
|
1149
|
-
|
|
1217
|
+
Pass a unique, descriptive name as the second argument of `m.constraint(c, name)`:
|
|
1150
1218
|
|
|
1151
1219
|
```typescript
|
|
1152
1220
|
m.constraint(expr`${x} + ${y} <= 100`, "budget_cap");
|
|
1153
1221
|
```
|
|
1154
1222
|
|
|
1155
1223
|
### Why Name Constraints?
|
|
1156
|
-
1. **Model Inspection (`m.toLpString()`)**: Exported CPLEX LP strings retain these descriptive names (sanitized if needed) so you can inspect the
|
|
1157
|
-
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`).
|
|
1158
1226
|
3. **Direct Lookup (`m.getConstraintByName()`)**: Retrieve a constraint by name after solving to inspect its `dual` or `slack` value.
|
|
1159
1227
|
|
|
1160
1228
|
---
|
|
1161
1229
|
|
|
1162
|
-
##
|
|
1230
|
+
## Aggregated Constraints (`sum` & `dot`)
|
|
1163
1231
|
|
|
1164
|
-
Use `sum()`
|
|
1232
|
+
Use `sum(...)` and `dot(..., ...)` directly inside `expr` to express linear aggregations over arrays and dictionaries cleanly:
|
|
1165
1233
|
|
|
1166
1234
|
```typescript
|
|
1167
|
-
import { Model,
|
|
1235
|
+
import { Model, expr } from "@ohtaman/washi";
|
|
1168
1236
|
|
|
1169
|
-
const
|
|
1237
|
+
const m = new Model();
|
|
1238
|
+
const items = ["A", "B", "C"] as const;
|
|
1170
1239
|
const weights = { A: 5, B: 10, C: 15 };
|
|
1171
1240
|
const selected = m.binary(items);
|
|
1172
1241
|
|
|
1242
|
+
// Select at most 2 items
|
|
1243
|
+
m.constraint(expr`sum(${selected}) <= 2`, "max_items");
|
|
1244
|
+
|
|
1173
1245
|
// Total weight cannot exceed capacity 25
|
|
1174
|
-
m.constraint(
|
|
1175
|
-
expr`${sum(items, (i) => selected[i].mul(weights[i]))} <= 25`,
|
|
1176
|
-
"capacity_limit"
|
|
1177
|
-
);
|
|
1246
|
+
m.constraint(expr`dot(${weights}, ${selected}) <= 25`, "capacity_limit");
|
|
1178
1247
|
```
|
|
1179
1248
|
|
|
1180
1249
|
---
|
|
1181
1250
|
|
|
1182
|
-
##
|
|
1251
|
+
## Conditional / Indicator Constraints (`m.indicator`)
|
|
1183
1252
|
|
|
1184
|
-
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()`:
|
|
1185
1254
|
|
|
1186
1255
|
```typescript
|
|
1187
1256
|
const useDiscount = m.binary("use_discount");
|
|
1188
1257
|
const cartTotal = m.continuous("cart_total", { lb: 0, ub: 10_000 });
|
|
1189
1258
|
|
|
1190
|
-
// If useDiscount == 1,
|
|
1259
|
+
// If useDiscount == 1, enforce cartTotal >= 100
|
|
1191
1260
|
// WaSHI automatically derives a safe, tight Big-M from the variable bounds
|
|
1192
1261
|
m.indicator(useDiscount, expr`${cartTotal} >= 100`, {
|
|
1193
1262
|
activeValue: 1,
|
|
@@ -1197,16 +1266,16 @@ m.indicator(useDiscount, expr`${cartTotal} >= 100`, {
|
|
|
1197
1266
|
|
|
1198
1267
|
### Safe Automatic Big-M Derivation & Bounds
|
|
1199
1268
|
|
|
1200
|
-
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`):
|
|
1201
1270
|
- For `<=` constraints, variables with positive coefficients require finite upper bounds (`ub`), and negative coefficients require finite lower bounds (`lb`).
|
|
1202
1271
|
- For `>=` constraints, variables with positive coefficients require finite lower bounds (`lb`), and negative coefficients require finite upper bounds (`ub`).
|
|
1203
|
-
- For `==` constraints,
|
|
1272
|
+
- For `==` constraints, bounds are derived separately for both the `<=` and `>=` directions (generating `name_le` and `name_ge`).
|
|
1204
1273
|
|
|
1205
|
-
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`.
|
|
1206
1275
|
|
|
1207
|
-
### Explicit Override
|
|
1276
|
+
### Explicit `M` Override
|
|
1208
1277
|
|
|
1209
|
-
You can override the derived Big-M by
|
|
1278
|
+
You can override the derived Big-M by passing `{ M: number }`:
|
|
1210
1279
|
|
|
1211
1280
|
```typescript
|
|
1212
1281
|
m.indicator(useDiscount, expr`${cartTotal} >= 100`, {
|
|
@@ -1216,15 +1285,13 @@ m.indicator(useDiscount, expr`${cartTotal} >= 100`, {
|
|
|
1216
1285
|
```
|
|
1217
1286
|
|
|
1218
1287
|
> [!TIP]
|
|
1219
|
-
> **
|
|
1220
|
-
> 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.
|
|
1221
|
-
> 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.
|
|
1222
1289
|
|
|
1223
1290
|
---
|
|
1224
1291
|
|
|
1225
|
-
##
|
|
1292
|
+
## Propositional Logic & SMT Constraints (`BoolVar`, `and`, `or`, `not`, `implies`, `xor`)
|
|
1226
1293
|
|
|
1227
|
-
|
|
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**:
|
|
1228
1295
|
|
|
1229
1296
|
```typescript
|
|
1230
1297
|
import { Model, expr, and, or, not, implies, xor } from "@ohtaman/washi";
|
|
@@ -1233,14 +1300,14 @@ import "@ohtaman/washi/solvers/z3";
|
|
|
1233
1300
|
const m = new Model({ solver: "z3" });
|
|
1234
1301
|
const isAdmin = m.bool("isAdmin");
|
|
1235
1302
|
const hasMfa = m.bool("hasMfa");
|
|
1236
|
-
const
|
|
1303
|
+
const clearance = m.integer("clearance", { lb: 0, ub: 5 });
|
|
1237
1304
|
|
|
1238
1305
|
// Method syntax on BoolVar / BoolOp:
|
|
1239
1306
|
m.constraint(isAdmin.implies(hasMfa), "admin_requires_mfa");
|
|
1240
1307
|
|
|
1241
1308
|
// Mixing boolean propositions with linear arithmetic comparisons:
|
|
1242
1309
|
m.constraint(
|
|
1243
|
-
implies(isAdmin, expr`${
|
|
1310
|
+
implies(isAdmin, expr`${clearance} >= 4`),
|
|
1244
1311
|
"admin_clearance_level"
|
|
1245
1312
|
);
|
|
1246
1313
|
|
|
@@ -1254,27 +1321,32 @@ m.constraint(and(or(isAdmin, hasMfa), not(xor(isAdmin, hasMfa))), "policy");
|
|
|
1254
1321
|
| :--- | :--- | :--- |
|
|
1255
1322
|
| **Trigger variable** | Numeric `Binary` variable (`m.binary()`) | `BoolVar` (`m.bool()`), `BoolOp`, or `Constraint` |
|
|
1256
1323
|
| **Consequent** | Linear `Constraint` (`<=`, `>=`, `==`) | `BoolVar`, `BoolOp`, or linear `Constraint` |
|
|
1257
|
-
| **Target Solvers** | **All MIP/LP solvers** (HiGHS, GLPK, Z3) via Big-M linearization | **
|
|
1258
|
-
| **
|
|
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) |
|
|
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.
|
|
1259
1329
|
|
|
1260
1330
|
---
|
|
1261
1331
|
|
|
1262
|
-
##
|
|
1332
|
+
## Dual Values, Shadow Prices & Lookups
|
|
1263
1333
|
|
|
1264
|
-
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()`):
|
|
1265
1335
|
|
|
1266
1336
|
```typescript
|
|
1267
|
-
m.constraint(expr`${x} + 2 * ${y} <= 40`, "wood");
|
|
1337
|
+
const woodConstraint = m.constraint(expr`${x} + 2 * ${y} <= 40`, "wood");
|
|
1268
1338
|
|
|
1269
1339
|
await m.solve();
|
|
1270
1340
|
|
|
1271
|
-
|
|
1272
|
-
console.log("
|
|
1273
|
-
console.log("Slack surplus:", woodConstraint?.slack);
|
|
1341
|
+
console.log("Shadow price (dual):", woodConstraint.dual);
|
|
1342
|
+
console.log("Slack surplus:", woodConstraint.slack);
|
|
1274
1343
|
console.log("Total linear constraints:", m.numConstrs);
|
|
1275
1344
|
```
|
|
1276
1345
|
|
|
1277
|
-
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).
|
|
1278
1350
|
|
|
1279
1351
|
---
|
|
1280
1352
|
|
|
@@ -1305,9 +1377,12 @@ m.constraint(expr`2 * ${x} + 3 * ${y} <= 100`, "resource_limit");
|
|
|
1305
1377
|
|
|
1306
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`.
|
|
1307
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
|
+
|
|
1308
1383
|
---
|
|
1309
1384
|
|
|
1310
|
-
##
|
|
1385
|
+
## Solving the Model (`m.solve()` vs. `m.check()`)
|
|
1311
1386
|
|
|
1312
1387
|
WaSHI provides two asynchronous execution entrypoints:
|
|
1313
1388
|
|
|
@@ -1343,7 +1418,7 @@ You can also pass a solver name directly as the first argument: `await m.solve("
|
|
|
1343
1418
|
|
|
1344
1419
|
---
|
|
1345
1420
|
|
|
1346
|
-
##
|
|
1421
|
+
## Real-Time Progress (`onProgress`) & Cancellation (`AbortSignal`)
|
|
1347
1422
|
|
|
1348
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`:
|
|
1349
1424
|
|
|
@@ -1389,7 +1464,7 @@ console.log("Has incumbent:", m.hasPrimalSolution); // true if an incumbent was
|
|
|
1389
1464
|
|
|
1390
1465
|
---
|
|
1391
1466
|
|
|
1392
|
-
##
|
|
1467
|
+
## Reading Results, Bounds & Solver Status
|
|
1393
1468
|
|
|
1394
1469
|
After solving, query the status, optimal values, and MIP optimality bounds on the `Model`:
|
|
1395
1470
|
|
|
@@ -1437,7 +1512,7 @@ If an integer-feasible incumbent was found before hitting `timeLimit` or `signal
|
|
|
1437
1512
|
|
|
1438
1513
|
---
|
|
1439
1514
|
|
|
1440
|
-
##
|
|
1515
|
+
## Model Mutations & Execution Validity
|
|
1441
1516
|
|
|
1442
1517
|
A `Model` instance keeps its solution state synchronized across mutations made through `Model` methods and `Model` / `Var` / `Constraint` property setters:
|
|
1443
1518
|
|
|
@@ -1457,13 +1532,13 @@ When building real-world optimization and configuration systems, two verificatio
|
|
|
1457
1532
|
1. **Why is my model infeasible?** Which specific business rules contradict each other?
|
|
1458
1533
|
2. **Does the solver's solution genuinely satisfy every constraint?** How do I verify tolerances, integrality, and formulations?
|
|
1459
1534
|
|
|
1460
|
-
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`).
|
|
1461
1536
|
|
|
1462
1537
|
---
|
|
1463
1538
|
|
|
1464
|
-
##
|
|
1539
|
+
## Conflict Diagnosis (`unsatCore` / `conflicts`)
|
|
1465
1540
|
|
|
1466
|
-
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.
|
|
1467
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:
|
|
1468
1543
|
|
|
1469
1544
|
```typescript
|
|
@@ -1495,109 +1570,613 @@ Notice that `routine_operating_cost` is **not** in the core, because only `minim
|
|
|
1495
1570
|
> - **Use `m.check({ solver: "z3" })` for Unsat Core extraction**: `m.solve()` runs optimization (`Z3.Optimize`), whereas `m.check()` runs tracked satisfiability checking (`Z3.Solver.assertAndTrack`) and populates `result.conflicts` / `m.conflicts` (and alias `m.unsatCore`).
|
|
1496
1571
|
> - **Name your constraints**: Variable bounds (`lb`, `ub`) act as background domain declarations. To include a bound or business rule in the reported `unsatCore` list, register it as a named constraint via `m.constraint(expr`...`, "rule_name")`.
|
|
1497
1572
|
|
|
1498
|
-
### Workflow:
|
|
1573
|
+
### Workflow: Infeasibility Diagnosis with HiGHS or Z3
|
|
1499
1574
|
|
|
1500
|
-
Because WaSHI models are solver-independent, you can
|
|
1575
|
+
Because WaSHI models are solver-independent, you can diagnose infeasibilities using either solver:
|
|
1576
|
+
1. **Built-in HiGHS & GLPK (default)**: `await m.check()` uses a single-solve elastic slack formulation ($\min \sum s_i$) to identify which constraints required relaxation (`diag.conflicts`), requiring no extra dependencies.
|
|
1577
|
+
2. **Z3 SMT Solver**: `await m.check({ solver: "z3" })` uses SMT tracking to extract an exact unsatisfiable core (`diag.unsatCore` / `diag.conflicts`).
|
|
1501
1578
|
|
|
1502
1579
|
```typescript
|
|
1503
1580
|
const status = await m.solve(); // Fast MIP solve with default HiGHS
|
|
1504
1581
|
|
|
1505
|
-
if (status === "Infeasible") {
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1582
|
+
if (status === "Infeasible") {
|
|
1583
|
+
// Option 1: Built-in single-solve elastic diagnosis with HiGHS
|
|
1584
|
+
const diag = await m.check();
|
|
1585
|
+
console.error("Model is infeasible due to conflicting rules:", diag.conflicts);
|
|
1586
|
+
|
|
1587
|
+
// Option 2: Exact SMT unsat core diagnosis with Z3 (requires import "@ohtaman/washi/solvers/z3")
|
|
1588
|
+
// const z3Diag = await m.check({ solver: "z3" });
|
|
1589
|
+
// console.error("Z3 Unsat Core:", z3Diag.unsatCore);
|
|
1590
|
+
}
|
|
1591
|
+
```
|
|
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
|
+
|
|
1605
|
+
---
|
|
1606
|
+
|
|
1607
|
+
## Independent Solution Validation (`m.validateSolution()`)
|
|
1608
|
+
|
|
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:
|
|
1610
|
+
|
|
1611
|
+
```typescript
|
|
1612
|
+
const status = await m.solve();
|
|
1613
|
+
|
|
1614
|
+
if (m.hasPrimalSolution) {
|
|
1615
|
+
const report = m.validateSolution({
|
|
1616
|
+
feasibilityTolerance: 1e-6, // Max allowed violation for bounds and linear constraints
|
|
1617
|
+
integralityTolerance: 1e-6, // Max distance from nearest integer for Integer/Binary vars
|
|
1618
|
+
evaluateLogicalConstraints: true,
|
|
1619
|
+
});
|
|
1620
|
+
|
|
1621
|
+
console.log("Valid:", report.valid);
|
|
1622
|
+
console.log("Complete:", report.complete);
|
|
1623
|
+
console.log("Max violation:", report.maxViolation);
|
|
1624
|
+
console.log("Summary:", report.summary);
|
|
1625
|
+
|
|
1626
|
+
if (!report.valid) {
|
|
1627
|
+
console.warn("Violations detected:", report.violations);
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
```
|
|
1631
|
+
|
|
1632
|
+
### `SolutionValidationReport` Summary
|
|
1633
|
+
|
|
1634
|
+
| Property | Type | Description |
|
|
1635
|
+
| :--- | :--- | :--- |
|
|
1636
|
+
| `valid` | `boolean` | `true` iff `complete === true`, `uncheckedConstraints.length === 0`, and `violations.length === 0`. |
|
|
1637
|
+
| `complete` | `boolean` | `true` when all variables, Boolean variables, linear constraints, and logical constraints were evaluated. |
|
|
1638
|
+
| `maxViolation` | `number` | Largest numeric violation magnitude across all evaluated bounds, integrality checks, and constraints. |
|
|
1639
|
+
| `violations` | `SolutionViolation[]` | Flat list of violations (`"missing_value"`, `"non_finite_value"`, `"invalid_boolean_value"`, `"lower_bound"`, `"upper_bound"`, `"integrality"`, `"linear_constraint"`, `"logical_constraint"`, `"unchecked_constraint"`). |
|
|
1640
|
+
| `violationsByName` | `Record<string, SolutionViolation[]>` | Violations grouped by variable or constraint name. |
|
|
1641
|
+
| `uncheckedConstraints` | `string[]` | Names of constraints that could not be evaluated. |
|
|
1642
|
+
| `missingVariables` | `string[]` | Names of numeric or Boolean variables whose values were `null` or `undefined`. |
|
|
1643
|
+
| `summary` | `SolutionValidationSummary` | Counts of checked vs. total variables, Boolean variables, linear constraints, and logical constraints. |
|
|
1644
|
+
|
|
1645
|
+
---
|
|
1646
|
+
|
|
1647
|
+
## Inspecting the LP Formulation (`m.toLpString()`)
|
|
1648
|
+
|
|
1649
|
+
When debugging coefficients, signs, or Big-M indicator expansions, call `m.toLpString()` to export the model in standard CPLEX/HiGHS `.lp` format:
|
|
1650
|
+
|
|
1651
|
+
```typescript
|
|
1652
|
+
const m = new Model("DebugModel");
|
|
1653
|
+
const x = m.integer("x", { lb: 0, ub: 10 });
|
|
1654
|
+
m.objective(maximize(expr`5 * ${x}`));
|
|
1655
|
+
m.constraint(expr`2 * ${x} <= 15`, "resource_limit");
|
|
1656
|
+
|
|
1657
|
+
console.log(m.toLpString());
|
|
1658
|
+
/*
|
|
1659
|
+
Maximize
|
|
1660
|
+
obj: 5 x
|
|
1661
|
+
Subject To
|
|
1662
|
+
resource_limit: 2 x <= 15
|
|
1663
|
+
Bounds
|
|
1664
|
+
0 <= x <= 10
|
|
1665
|
+
Generals
|
|
1666
|
+
x
|
|
1667
|
+
End
|
|
1668
|
+
*/
|
|
1669
|
+
```
|
|
1670
|
+
|
|
1671
|
+
---
|
|
1672
|
+
|
|
1673
|
+
## Unit Testing Application Logic with `MockSolver`
|
|
1674
|
+
|
|
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:
|
|
1676
|
+
|
|
1677
|
+
```typescript
|
|
1678
|
+
import { Model, MockSolver, expr, maximize } from "@ohtaman/washi";
|
|
1679
|
+
|
|
1680
|
+
const mock = new MockSolver({
|
|
1681
|
+
status: "Optimal",
|
|
1682
|
+
objectiveValue: 420,
|
|
1683
|
+
values: { x: 10, y: 20 },
|
|
1684
|
+
});
|
|
1685
|
+
|
|
1686
|
+
const m = new Model({ solver: mock });
|
|
1687
|
+
const x = m.integer("x");
|
|
1688
|
+
const y = m.integer("y");
|
|
1689
|
+
m.objective(maximize(expr`10 * ${x} + 16 * ${y}`));
|
|
1690
|
+
|
|
1691
|
+
await m.solve();
|
|
1692
|
+
console.log(m.objectiveValue); // 420
|
|
1693
|
+
console.log(x.value, y.value); // 10, 20
|
|
1694
|
+
```
|
|
1695
|
+
|
|
1696
|
+
---
|
|
1697
|
+
|
|
1698
|
+
# Decomposition Methods (`solveColumnGeneration` & `solveBenders`)
|
|
1699
|
+
|
|
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.
|
|
1701
|
+
|
|
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). |
|
|
1706
|
+
|
|
1707
|
+
---
|
|
1708
|
+
|
|
1709
|
+
## Foundational Primitives
|
|
1710
|
+
|
|
1711
|
+
WaSHI exposes four lower-level primitives used internally by the decomposition orchestrators and available directly for custom algorithms:
|
|
1712
|
+
|
|
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:
|
|
1715
|
+
|
|
1716
|
+
```typescript
|
|
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");
|
|
1725
|
+
await m.solve();
|
|
1726
|
+
|
|
1727
|
+
console.log(c.dual); // Dual value / shadow price
|
|
1728
|
+
console.log(m.solution?.duals.capacity); // Lookup by constraint name
|
|
1729
|
+
```
|
|
1730
|
+
|
|
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:
|
|
1733
|
+
|
|
1734
|
+
```typescript
|
|
1735
|
+
// Solves the continuous LP relaxation and populates m.solution.duals
|
|
1736
|
+
const status = await m.relax();
|
|
1737
|
+
console.log("LP relaxation bound:", m.objectiveValue);
|
|
1738
|
+
```
|
|
1739
|
+
|
|
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:
|
|
1742
|
+
|
|
1743
|
+
```typescript
|
|
1744
|
+
const col = m.addColumn({
|
|
1745
|
+
name: "pattern_1",
|
|
1746
|
+
type: "Integer", // or "Continuous" / "Binary"
|
|
1747
|
+
lb: 0,
|
|
1748
|
+
cost: 1, // Objective coefficient
|
|
1749
|
+
coefficients: {
|
|
1750
|
+
capacity: 2,
|
|
1751
|
+
},
|
|
1752
|
+
});
|
|
1753
|
+
```
|
|
1754
|
+
|
|
1755
|
+
### 4. Dynamic Constraint Term Updates (`c.addTerm()` / `c.setTerm()`)
|
|
1756
|
+
Increment or overwrite the coefficient of a variable inside an existing linear constraint:
|
|
1757
|
+
|
|
1758
|
+
```typescript
|
|
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
|
|
1764
|
+
```
|
|
1765
|
+
|
|
1766
|
+
---
|
|
1767
|
+
|
|
1768
|
+
## Column Generation (`solveColumnGeneration`)
|
|
1769
|
+
|
|
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.
|
|
1776
|
+
|
|
1777
|
+
### Pricing with a WaSHI Subproblem Model
|
|
1778
|
+
|
|
1779
|
+
```typescript
|
|
1780
|
+
import { Model, expr, minimize, maximize } from "@ohtaman/washi";
|
|
1781
|
+
|
|
1782
|
+
const rollWidth = 100;
|
|
1783
|
+
|
|
1784
|
+
// 1. Restricted Master Problem (RMP) with simple single-item initial patterns
|
|
1785
|
+
const master = new Model("CuttingStockMaster");
|
|
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
|
|
1789
|
+
|
|
1790
|
+
master.objective(minimize(expr`${pA} + ${pB} + ${pC}`));
|
|
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");
|
|
1794
|
+
|
|
1795
|
+
// 2. Run Column Generation
|
|
1796
|
+
const result = await master.solveColumnGeneration({
|
|
1797
|
+
finalizeAsMIP: true,
|
|
1798
|
+
pricing: async () => {
|
|
1799
|
+
// Knapsack pricing subproblem: find a pattern maximizing total dual value
|
|
1800
|
+
const sub = new Model("KnapsackPricing");
|
|
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 });
|
|
1804
|
+
|
|
1805
|
+
const dualA = demandA.dual ?? 0;
|
|
1806
|
+
const dualB = demandB.dual ?? 0;
|
|
1807
|
+
const dualC = demandC.dual ?? 0;
|
|
1808
|
+
sub.objective(maximize(expr`${dualA} * ${aA} + ${dualB} * ${aB} + ${dualC} * ${aC}`));
|
|
1809
|
+
sub.constraint(expr`45 * ${aA} + 36 * ${aB} + 16 * ${aC} <= ${rollWidth}`);
|
|
1810
|
+
|
|
1811
|
+
await sub.solve();
|
|
1812
|
+
|
|
1813
|
+
return {
|
|
1814
|
+
cost: 1,
|
|
1815
|
+
type: "Integer",
|
|
1816
|
+
coefficients: {
|
|
1817
|
+
demand_A: Math.round(aA.value ?? 0),
|
|
1818
|
+
demand_B: Math.round(aB.value ?? 0),
|
|
1819
|
+
demand_C: Math.round(aC.value ?? 0),
|
|
1820
|
+
},
|
|
1821
|
+
};
|
|
1822
|
+
},
|
|
1823
|
+
});
|
|
1824
|
+
|
|
1825
|
+
console.log("Optimal rolls:", result.objectiveValue); // 14
|
|
1826
|
+
console.log("Iterations:", result.iterations);
|
|
1827
|
+
console.log("Columns added:", result.columnsAdded);
|
|
1828
|
+
```
|
|
1829
|
+
|
|
1830
|
+
### Pricing with a Custom Algorithm (`getDual`)
|
|
1831
|
+
|
|
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`:
|
|
1833
|
+
|
|
1834
|
+
```typescript
|
|
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;
|
|
1858
|
+
},
|
|
1859
|
+
});
|
|
1860
|
+
```
|
|
1861
|
+
|
|
1862
|
+
---
|
|
1863
|
+
|
|
1864
|
+
## Benders Decomposition (`solveBenders`)
|
|
1865
|
+
|
|
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**.
|
|
1867
|
+
|
|
1868
|
+
### Automatic Cut Generation (`sub.link()`)
|
|
1869
|
+
|
|
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:
|
|
1871
|
+
|
|
1872
|
+
```typescript
|
|
1873
|
+
import { Model, expr, minimize } from "@ohtaman/washi";
|
|
1874
|
+
|
|
1875
|
+
// 1. Master problem: binary facility opening decisions
|
|
1876
|
+
const master = new Model("FacilityMaster");
|
|
1877
|
+
const y1 = master.binary("open_F1");
|
|
1878
|
+
const y2 = master.binary("open_F2");
|
|
1879
|
+
|
|
1880
|
+
// Fixed opening costs (theta for operational subproblem cost is managed automatically)
|
|
1881
|
+
master.objective(minimize(expr`100 * ${y1} + 120 * ${y2}`));
|
|
1882
|
+
master.constraint(expr`50 * ${y1} + 60 * ${y2} >= 75`, "total_capacity_floor");
|
|
1883
|
+
|
|
1884
|
+
// 2. Solve with automatic Benders cut generation
|
|
1885
|
+
const result = await master.solveBenders({
|
|
1886
|
+
gapTolerance: 1e-4,
|
|
1887
|
+
subproblem: () => {
|
|
1888
|
+
const sub = new Model("DispatchSubproblem");
|
|
1889
|
+
const x11 = sub.continuous("x_11", { lb: 0 });
|
|
1890
|
+
const x12 = sub.continuous("x_12", { lb: 0 });
|
|
1891
|
+
const x13 = sub.continuous("x_13", { lb: 0 });
|
|
1892
|
+
const x21 = sub.continuous("x_21", { lb: 0 });
|
|
1893
|
+
const x22 = sub.continuous("x_22", { lb: 0 });
|
|
1894
|
+
const x23 = sub.continuous("x_23", { lb: 0 });
|
|
1895
|
+
|
|
1896
|
+
sub.objective(
|
|
1897
|
+
minimize(
|
|
1898
|
+
expr`3 * ${x11} + 4 * ${x12} + 2 * ${x13} + 4 * ${x21} + 2 * ${x22} + 3 * ${x23}`
|
|
1899
|
+
)
|
|
1900
|
+
);
|
|
1901
|
+
|
|
1902
|
+
sub.constraint(expr`${x11} + ${x21} >= 20`, "demand_C1");
|
|
1903
|
+
sub.constraint(expr`${x12} + ${x22} >= 25`, "demand_C2");
|
|
1904
|
+
sub.constraint(expr`${x13} + ${x23} >= 30`, "demand_C3");
|
|
1905
|
+
|
|
1906
|
+
// Link subproblem dispatch flows to master binary variables y1, y2
|
|
1907
|
+
sub.link(expr`${x11} + ${x12} + ${x13} <= 50 * ${y1}`);
|
|
1908
|
+
sub.link(expr`${x21} + ${x22} + ${x23} <= 60 * ${y2}`);
|
|
1909
|
+
|
|
1910
|
+
return sub;
|
|
1911
|
+
},
|
|
1912
|
+
});
|
|
1913
|
+
|
|
1914
|
+
console.log("Optimal total cost:", result.objectiveValue); // 390
|
|
1915
|
+
console.log("Cuts added:", result.cutsAdded);
|
|
1916
|
+
console.log("Open facilities:", result.values);
|
|
1917
|
+
```
|
|
1918
|
+
|
|
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)
|
|
1923
|
+
|
|
1924
|
+
For combinatorial separation (such as lazy subtour elimination) or custom analytical cuts, return one `BendersCut` object, an array `BendersCut[]`, or `null` when converged:
|
|
1925
|
+
|
|
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
|
+
|
|
1934
|
+
const result = await master.solveBenders({
|
|
1935
|
+
theta,
|
|
1936
|
+
gapTolerance: 1e-4,
|
|
1937
|
+
subproblem: ({ getMasterValue }) => {
|
|
1938
|
+
const yVal = getMasterValue(y);
|
|
1939
|
+
const estTheta = getMasterValue(theta);
|
|
1940
|
+
|
|
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) {
|
|
1944
|
+
return {
|
|
1945
|
+
kind: "optimality", // or "feasibility" for lazy combinatorial cuts
|
|
1946
|
+
constraint: expr`${theta} >= 100 - 70 * ${y}`,
|
|
1947
|
+
subproblemObjective: 40 * yVal + actualSubCost,
|
|
1948
|
+
};
|
|
1949
|
+
}
|
|
1950
|
+
return null; // Converged
|
|
1951
|
+
},
|
|
1952
|
+
});
|
|
1953
|
+
|
|
1954
|
+
console.log(result.objectiveValue); // 70 (y = 1, theta = 30)
|
|
1955
|
+
```
|
|
1956
|
+
|
|
1957
|
+
---
|
|
1958
|
+
|
|
1959
|
+
## Related Examples & Formulation Tips
|
|
1960
|
+
|
|
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.
|
|
1965
|
+
|
|
1966
|
+
---
|
|
1967
|
+
|
|
1968
|
+
# Formulation Patterns & Optimization Tips
|
|
1969
|
+
|
|
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$:
|
|
1981
|
+
|
|
1982
|
+
```typescript
|
|
1983
|
+
import { Model, expr } from "@ohtaman/washi";
|
|
1984
|
+
|
|
1985
|
+
const m = new Model("SemiContinuousBatch");
|
|
1986
|
+
const batch = m.continuous("batch", { lb: 0, ub: 100 });
|
|
1987
|
+
const isActive = m.binary("is_active");
|
|
1988
|
+
|
|
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
|
+
```
|
|
1993
|
+
|
|
1994
|
+
### Minimal-Perturbation Rescheduling vs. Warm Starting
|
|
1995
|
+
|
|
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.
|
|
1997
|
+
|
|
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:
|
|
1999
|
+
|
|
2000
|
+
$$\text{Changes} = \sum_{i:\, x_i^{\text{prev}} = 0} x_i + \sum_{i:\, x_i^{\text{prev}} = 1} (1 - x_i)$$
|
|
2001
|
+
|
|
2002
|
+
```typescript
|
|
2003
|
+
import { Model, expr, minimize } from "@ohtaman/washi";
|
|
2004
|
+
|
|
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
|
+
);
|
|
2010
|
+
|
|
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));
|
|
2013
|
+
|
|
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})`));
|
|
2017
|
+
```
|
|
2018
|
+
|
|
2019
|
+
---
|
|
2020
|
+
|
|
2021
|
+
## 2. Linearizing Non-Linear Expressions
|
|
2022
|
+
|
|
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$$
|
|
2028
|
+
|
|
2029
|
+
```typescript
|
|
2030
|
+
import { Model, expr, maximize } from "@ohtaman/washi";
|
|
2031
|
+
|
|
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 });
|
|
2037
|
+
|
|
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)
|
|
1509
2098
|
```
|
|
1510
2099
|
|
|
1511
|
-
|
|
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.
|
|
1512
2102
|
|
|
1513
|
-
|
|
2103
|
+
### Symmetry Breaking for Identical Resources
|
|
1514
2104
|
|
|
1515
|
-
|
|
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.
|
|
1516
2106
|
|
|
1517
|
-
|
|
1518
|
-
const status = await m.solve();
|
|
2107
|
+
Impose a canonical ordering ($u_0 \ge u_1 \ge \dots \ge u_{K-1}$) so identical resources are activated in index order:
|
|
1519
2108
|
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
feasibilityTolerance: 1e-6, // Max allowed violation for bounds and linear constraints
|
|
1523
|
-
integralityTolerance: 1e-6, // Max distance from nearest integer for Integer/Binary vars
|
|
1524
|
-
evaluateLogicalConstraints: true,
|
|
1525
|
-
});
|
|
2109
|
+
```typescript
|
|
2110
|
+
import { Model, expr } from "@ohtaman/washi";
|
|
1526
2111
|
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
console.log("Summary:", report.summary);
|
|
2112
|
+
const m = new Model("SymmetryBreaking");
|
|
2113
|
+
const numTrucks = 4;
|
|
2114
|
+
const truckUsed = m.binary(numTrucks, { prefix: "truck_used" });
|
|
1531
2115
|
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
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
|
+
);
|
|
1535
2122
|
}
|
|
1536
2123
|
```
|
|
1537
2124
|
|
|
1538
|
-
|
|
2125
|
+
---
|
|
1539
2126
|
|
|
1540
|
-
|
|
1541
|
-
| :--- | :--- | :--- |
|
|
1542
|
-
| `valid` | `boolean` | `true` iff `complete === true`, `uncheckedConstraints.length === 0`, and `violations.length === 0`. |
|
|
1543
|
-
| `complete` | `boolean` | `true` when all variables, Boolean variables, linear constraints, and logical constraints were evaluated. |
|
|
1544
|
-
| `maxViolation` | `number` | Largest numeric violation magnitude across all evaluated bounds, integrality checks, and constraints. |
|
|
1545
|
-
| `violations` | `SolutionViolation[]` | Flat list of violations (`"missing_value"`, `"non_finite_value"`, `"invalid_boolean_value"`, `"lower_bound"`, `"upper_bound"`, `"integrality"`, `"linear_constraint"`, `"logical_constraint"`, `"unchecked_constraint"`). |
|
|
1546
|
-
| `violationsByName` | `Record<string, SolutionViolation[]>` | Violations grouped by variable or constraint name. |
|
|
1547
|
-
| `uncheckedConstraints` | `string[]` | Names of constraints that could not be evaluated. |
|
|
1548
|
-
| `missingVariables` | `string[]` | Names of numeric or Boolean variables whose values were `null` or `undefined`. |
|
|
1549
|
-
| `summary` | `SolutionValidationSummary` | Counts of checked vs. total variables, Boolean variables, linear constraints, and logical constraints. |
|
|
2127
|
+
## 4. Multi-Objective & MIP Stopping Design
|
|
1550
2128
|
|
|
1551
|
-
|
|
2129
|
+
### Weighted Sum vs. Lexicographic Two-Stage Optimization
|
|
1552
2130
|
|
|
1553
|
-
|
|
2131
|
+
When balancing competing goals—such as minimizing total cost ($f_1$) while also minimizing peak workload ($f_2$)—two approaches are standard:
|
|
1554
2132
|
|
|
1555
|
-
|
|
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$:
|
|
1556
2135
|
|
|
1557
2136
|
```typescript
|
|
1558
|
-
|
|
1559
|
-
const x = m.integer("x", { lb: 0, ub: 10 });
|
|
1560
|
-
m.objective(maximize(expr`5 * ${x}`));
|
|
1561
|
-
m.constraint(expr`2 * ${x} <= 15`, "resource_limit");
|
|
2137
|
+
import { Model, expr, minimize } from "@ohtaman/washi";
|
|
1562
2138
|
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
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();
|
|
1575
2158
|
```
|
|
1576
2159
|
|
|
1577
|
-
|
|
2160
|
+
### MIP Tail-Off & Optimality Gap Tolerances
|
|
1578
2161
|
|
|
1579
|
-
|
|
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**).
|
|
1580
2163
|
|
|
1581
|
-
|
|
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.
|
|
1582
2167
|
|
|
1583
|
-
|
|
1584
|
-
import { Model, MockSolver, expr, maximize } from "@ohtaman/washi";
|
|
2168
|
+
---
|
|
1585
2169
|
|
|
1586
|
-
|
|
1587
|
-
status: "Optimal",
|
|
1588
|
-
objectiveValue: 420,
|
|
1589
|
-
values: { x: 10, y: 20 },
|
|
1590
|
-
});
|
|
2170
|
+
## 5. Decomposition Formulation Tips
|
|
1591
2171
|
|
|
1592
|
-
|
|
1593
|
-
const x = m.integer("x");
|
|
1594
|
-
const y = m.integer("y");
|
|
1595
|
-
m.objective(maximize(expr`10 * ${x} + 16 * ${y}`));
|
|
2172
|
+
When using [`solveColumnGeneration` or `solveBenders`](docs/guide/decomposition.md):
|
|
1596
2173
|
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
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)).
|
|
1601
2180
|
|
|
1602
2181
|
---
|
|
1603
2182
|
|
|
@@ -1611,9 +2190,9 @@ WaSHI is designed around a pluggable, decoupled solver architecture. You formula
|
|
|
1611
2190
|
|
|
1612
2191
|
| Solver | Engine License | Problem Classes | Best For | Status in WaSHI |
|
|
1613
2192
|
| :--- | :--- | :--- | :--- | :--- |
|
|
1614
|
-
| **HiGHS** | **MIT** | **LP, MIP, Convex QP** | Industrial-scale optimization, operations research, finance | **Built-in default** (zero configuration) |
|
|
1615
|
-
| **Z3** | **MIT** | **SMT, SAT, Logic, MIP** | Propositional logic, conflict diagnosis (`unsatCore`), rule engines | Optional plugin (`@ohtaman/washi/solvers/z3`) |
|
|
1616
|
-
| **GLPK** | **GPL-3.0** | **LP, MIP** | Academic benchmarks, legacy GLPK compatibility | Optional plugin (`@ohtaman/washi/solvers/glpk`) |
|
|
2193
|
+
| **[HiGHS](https://github.com/ERGO-Code/HiGHS)** | **MIT** | **LP, MIP, Convex QP** | Industrial-scale optimization, operations research, finance | **Built-in default** (zero configuration) |
|
|
2194
|
+
| **[Z3](https://github.com/Z3Prover/z3)** (via [`z3-solver`](https://www.npmjs.com/package/z3-solver)) | **MIT** | **SMT, SAT, Logic, MIP** | Propositional logic, conflict diagnosis (`unsatCore`), rule engines | Optional plugin (`@ohtaman/washi/solvers/z3`) |
|
|
2195
|
+
| **[GLPK](https://www.gnu.org/software/glpk/)** (via [`glpk.js`](https://www.npmjs.com/package/glpk.js)) | **GPL-3.0** | **LP, MIP** | Academic benchmarks, legacy GLPK compatibility | Optional plugin (`@ohtaman/washi/solvers/glpk`) |
|
|
1617
2196
|
| **Mock** | **MIT** | *Any* | Ultra-fast unit testing in CI without loading WebAssembly | Built-in |
|
|
1618
2197
|
|
|
1619
2198
|
---
|
|
@@ -1639,7 +2218,7 @@ const m3 = new Model({ solver: "glpk" });
|
|
|
1639
2218
|
|
|
1640
2219
|
## Solver Guides
|
|
1641
2220
|
|
|
1642
|
-
- [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.
|
|
1643
2222
|
- [Z3 Guide](docs/solvers/z3.md): SMT assertions, logic modeling, and Unsat Core analysis.
|
|
1644
2223
|
- [GLPK Guide](docs/solvers/glpk.md): GLPK adapter, GPL-3.0 licensing considerations, and browser usage.
|
|
1645
2224
|
- [Custom Solvers](docs/solvers/custom-solver.md): Step-by-step developer tutorial for integrating proprietary or cloud REST solvers.
|
|
@@ -1648,17 +2227,39 @@ const m3 = new Model({ solver: "glpk" });
|
|
|
1648
2227
|
|
|
1649
2228
|
# HiGHS Solver Engine
|
|
1650
2229
|
|
|
1651
|
-
**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).
|
|
2231
|
+
|
|
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.
|
|
1652
2233
|
|
|
1653
2234
|
---
|
|
1654
2235
|
|
|
1655
2236
|
## Key Features in WaSHI
|
|
1656
2237
|
|
|
1657
|
-
- **Built-in & Zero-Config**: Bundled directly with `@ohtaman/washi
|
|
2238
|
+
- **Built-in & Zero-Config**: Bundled directly with `@ohtaman/washi` with zero external runtime dependencies. No separate peer dependency or C++ compiler required.
|
|
1658
2239
|
- **Permissive MIT License**: 100% commercially friendly.
|
|
1659
|
-
- **Web Worker
|
|
2240
|
+
- **Background Web Worker Execution**: Automatically offloads solving to a dedicated Web Worker in browser environments to avoid freezing the UI thread.
|
|
1660
2241
|
- **Warm-Start Support (`initialValue`)**: Supports passing variable primal initial value hints (`initialValue`) to HiGHS `setSolution` for warm starting.
|
|
1661
|
-
- **Fast WASM Execution**:
|
|
2242
|
+
- **Fast WASM Execution**: Runs the directly compiled 32-bit WebAssembly binary at near-native speed.
|
|
2243
|
+
|
|
2244
|
+
---
|
|
2245
|
+
|
|
2246
|
+
## Architecture & WebAssembly Runtime
|
|
2247
|
+
|
|
2248
|
+
WaSHI compiles official HiGHS C++ source directly to WebAssembly (`highs.wasm`) with `-fwasm-exceptions` and single-threaded universal portability (`-DHIGHS_NO_DEFAULT_THREADS=ON`). By interfacing directly with HiGHS's native C API (`highs_c_api.h`), WaSHI achieves zero-copy solution extraction via typed array views on WebAssembly linear memory, rapid upstream release tracking, and a self-contained zero-dependency distribution.
|
|
2249
|
+
|
|
2250
|
+
### Callbacks & Solver Interactivity
|
|
2251
|
+
|
|
2252
|
+
- **Progress Monitoring & Cancellation (Supported)**:
|
|
2253
|
+
Through HiGHS's internal logging and interrupt hooks, WaSHI provides real-time progress callbacks (`onProgress`) and cooperative mid-solve cancellation via standard `AbortSignal`.
|
|
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.
|
|
2256
|
+
|
|
2257
|
+
### WebAssembly Asset Lifecycle & On-Demand Builds
|
|
2258
|
+
|
|
2259
|
+
HiGHS's WebAssembly binary (`public/highs.wasm`) and runtime driver (`runtime.js`) are managed via `highs.config.json` with cryptographic SHA-256 verification:
|
|
2260
|
+
- **Zero-Friction Verification**: Build and distribution pipelines run `node scripts/ensure-highs-wasm.mjs`, verifying local assets in milliseconds before bundling.
|
|
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.
|
|
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`).
|
|
1662
2263
|
|
|
1663
2264
|
---
|
|
1664
2265
|
|
|
@@ -1686,7 +2287,7 @@ const count = model.integer("count", { lb: 0, ub: 50 });
|
|
|
1686
2287
|
```
|
|
1687
2288
|
|
|
1688
2289
|
### 3. Convex Quadratic Programming (QP)
|
|
1689
|
-
Solves quadratic objective functions subject to linear constraints:
|
|
2290
|
+
Solves continuous models with convex quadratic objective functions subject to linear constraints:
|
|
1690
2291
|
|
|
1691
2292
|
```typescript
|
|
1692
2293
|
// Minimize risk/variance uniformly using expr:
|
|
@@ -1697,6 +2298,9 @@ model.objective(minimize(expr`0.5 * ${x}^2 + 0.5 * ${y}^2`));
|
|
|
1697
2298
|
// model.objective(minimize(quadObj));
|
|
1698
2299
|
```
|
|
1699
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
|
+
|
|
1700
2304
|
---
|
|
1701
2305
|
|
|
1702
2306
|
## Solver Options
|
|
@@ -1717,6 +2321,14 @@ await model.solve({
|
|
|
1717
2321
|
console.log(`Elapsed: ${ev.elapsedTime.toFixed(1)}s, Best: ${ev.objectiveValue}`);
|
|
1718
2322
|
},
|
|
1719
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
|
+
}
|
|
1720
2332
|
```
|
|
1721
2333
|
|
|
1722
2334
|
---
|
|
@@ -1741,6 +2353,16 @@ const syncModel = new Model({
|
|
|
1741
2353
|
|
|
1742
2354
|
---
|
|
1743
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
|
+
|
|
1744
2366
|
# Z3 SMT Solver Engine
|
|
1745
2367
|
|
|
1746
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.
|
|
@@ -1766,6 +2388,9 @@ npm install z3-solver
|
|
|
1766
2388
|
3. **Exact Mathematical Guarantees**:
|
|
1767
2389
|
Z3 can perform exact rational arithmetic without floating-point inaccuracies.
|
|
1768
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
|
+
|
|
1769
2394
|
---
|
|
1770
2395
|
|
|
1771
2396
|
## Basic Usage
|
|
@@ -2010,7 +2635,7 @@ export interface SolveModelData extends CheckModelData {
|
|
|
2010
2635
|
|
|
2011
2636
|
Custom solvers declare their supported features using `capabilities`. Before dispatching `solve()` or `check()`, `Model` validates:
|
|
2012
2637
|
- `supportsQP === false` when the objective contains quadratic terms (`QuadExpr`),
|
|
2013
|
-
-
|
|
2638
|
+
- when the model contains logical constraints (`m.logicalConstraints`) and the solver does not support SMT (`supportsSMT !== true`), `Model` automatically linearizes them into equivalent 0-1 MIP binary variables and linear constraints via `linearizeModelForMip`, and
|
|
2014
2639
|
- `supportsWarmStart !== true` when any variable has a primal `initialValue` set.
|
|
2015
2640
|
|
|
2016
2641
|
```typescript
|
|
@@ -2419,21 +3044,23 @@ describe("Custom Solver Integration", () => {
|
|
|
2419
3044
|
|
|
2420
3045
|
# Examples & API Finder
|
|
2421
3046
|
|
|
2422
|
-
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/).
|
|
2423
3048
|
|
|
2424
3049
|
## Examples
|
|
2425
3050
|
|
|
2426
3051
|
| Example | Problem Class | Solver | Key WaSHI APIs | Description | Code Editor |
|
|
2427
3052
|
| :--- | :--- | :--- | :--- | :--- | :--- |
|
|
2428
|
-
| **[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.
|
|
2429
|
-
| **[0-1 Knapsack](docs/examples/knapsack.md)** | Binary MIP | HiGHS | `m.binary(keys)`, `
|
|
2430
|
-
| **[Transportation Network](docs/examples/transportation.md)** | LP | HiGHS | `m.continuous(rows, cols)`, `
|
|
2431
|
-
| **[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.
|
|
2432
|
-
| **[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.
|
|
2433
|
-
| **[E-Commerce Coupon Optimizer](docs/examples/ecommerce-coupons.md)** | Indicator MIP | HiGHS | `m.indicator()`, `sum
|
|
2434
|
-
| **[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.
|
|
2435
|
-
| **[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.
|
|
2436
|
-
| **[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) |
|
|
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) |
|
|
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) |
|
|
2437
3064
|
|
|
2438
3065
|
## Find by WaSHI API (Reverse Index)
|
|
2439
3066
|
|
|
@@ -2444,13 +3071,15 @@ Looking for a specific modeling method or solver capability? Jump straight to th
|
|
|
2444
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) |
|
|
2445
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) |
|
|
2446
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) |
|
|
2447
|
-
| `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) |
|
|
2448
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) |
|
|
2449
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) |
|
|
2450
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) |
|
|
2451
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) |
|
|
2452
|
-
| `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) |
|
|
2453
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) |
|
|
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) |
|
|
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) |
|
|
2454
3083
|
| `solver: "glpk"` | Solving LP/MIP with GLPK plugin | [GLPK Solver Guide](docs/solvers/glpk.md) | [`08-glpk-solver-plugin.ts`](https://github.com/ohtaman/washi/blob/main/examples/node/08-glpk-solver-plugin.ts) |
|
|
2455
3084
|
|
|
2456
3085
|
## Running Examples Locally in Node.js
|
|
@@ -2469,6 +3098,8 @@ npx tsx examples/node/09-transportation-problem.ts
|
|
|
2469
3098
|
npx tsx examples/node/10-diet-sensitivity.ts
|
|
2470
3099
|
npx tsx examples/node/11-shift-scheduling.ts
|
|
2471
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
|
|
2472
3103
|
```
|
|
2473
3104
|
|
|
2474
3105
|
---
|
|
@@ -2477,7 +3108,7 @@ npx tsx examples/node/12-ecommerce-coupon-optimizer.ts
|
|
|
2477
3108
|
|
|
2478
3109
|
| Problem Class | Solver | Key APIs | Links |
|
|
2479
3110
|
| :--- | :--- | :--- | :--- |
|
|
2480
|
-
| 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> |
|
|
2481
3112
|
|
|
2482
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).
|
|
2483
3114
|
|
|
@@ -2507,9 +3138,9 @@ async function runProductionOptimization() {
|
|
|
2507
3138
|
// Objective: Maximize total profit ($45 per chair, $80 per table)
|
|
2508
3139
|
m.objective(maximize(expr`45 * ${chairs} + 80 * ${tables}`));
|
|
2509
3140
|
|
|
2510
|
-
// Resource constraints: declarative formulas via expr and
|
|
2511
|
-
m.constraint(expr`5 * ${chairs} + 20 * ${tables} <= 400`, "mahogany_limit")
|
|
2512
|
-
|
|
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");
|
|
2513
3144
|
|
|
2514
3145
|
const status = await m.solve();
|
|
2515
3146
|
|
|
@@ -2527,7 +3158,7 @@ runProductionOptimization();
|
|
|
2527
3158
|
|
|
2528
3159
|
| Problem Class | Solver | Key APIs | Links |
|
|
2529
3160
|
| :--- | :--- | :--- | :--- |
|
|
2530
|
-
| 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> |
|
|
2531
3162
|
|
|
2532
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.
|
|
2533
3164
|
|
|
@@ -2544,7 +3175,7 @@ $$\begin{aligned}
|
|
|
2544
3175
|
## Implementation in WaSHI
|
|
2545
3176
|
|
|
2546
3177
|
```typescript
|
|
2547
|
-
import { Model, maximize,
|
|
3178
|
+
import { Model, maximize, expr } from "@ohtaman/washi";
|
|
2548
3179
|
|
|
2549
3180
|
async function solveKnapsack() {
|
|
2550
3181
|
const m = new Model("Knapsack");
|
|
@@ -2564,17 +3195,18 @@ async function solveKnapsack() {
|
|
|
2564
3195
|
const values = Object.fromEntries(items.map((it) => [it.name, it.value]));
|
|
2565
3196
|
const weights = Object.fromEntries(items.map((it) => [it.name, it.weight]));
|
|
2566
3197
|
|
|
2567
|
-
// Maximize total value via dot
|
|
2568
|
-
m.objective(maximize(dot(values, x)));
|
|
3198
|
+
// Maximize total value via dot() inside expr
|
|
3199
|
+
m.objective(maximize(expr`dot(${values}, ${x})`));
|
|
2569
3200
|
|
|
2570
|
-
// Weight capacity constraint using
|
|
2571
|
-
m.constraint(expr
|
|
3201
|
+
// Weight capacity constraint using expr and constraint()
|
|
3202
|
+
m.constraint(expr`dot(${weights}, ${x}) <= ${maxCapacity}`, "capacity_limit");
|
|
2572
3203
|
|
|
2573
3204
|
const status = await m.solve();
|
|
2574
3205
|
|
|
2575
3206
|
console.log(`Status: ${status}`);
|
|
2576
3207
|
console.log(`Max Total Value: $${m.objectiveValue}`);
|
|
2577
|
-
|
|
3208
|
+
const packed = items.filter((it) => x[it.name].value === 1).map((it) => it.name);
|
|
3209
|
+
console.log("Packed Items:", packed.join(", "));
|
|
2578
3210
|
}
|
|
2579
3211
|
|
|
2580
3212
|
solveKnapsack();
|
|
@@ -2586,7 +3218,7 @@ solveKnapsack();
|
|
|
2586
3218
|
|
|
2587
3219
|
| Problem Class | Solver | Key APIs | Links |
|
|
2588
3220
|
| :--- | :--- | :--- | :--- |
|
|
2589
|
-
| 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> |
|
|
2590
3222
|
|
|
2591
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.
|
|
2592
3224
|
|
|
@@ -2617,7 +3249,7 @@ $$\begin{aligned}
|
|
|
2617
3249
|
## Implementation in WaSHI
|
|
2618
3250
|
|
|
2619
3251
|
```typescript
|
|
2620
|
-
import { Model,
|
|
3252
|
+
import { Model, minimize, expr } from "@ohtaman/washi";
|
|
2621
3253
|
|
|
2622
3254
|
const factories = ["Tokyo", "Nagoya", "Osaka"] as const;
|
|
2623
3255
|
const stores = ["North", "East", "West", "South"] as const;
|
|
@@ -2631,51 +3263,78 @@ const shippingCost = {
|
|
|
2631
3263
|
const supplies = { Tokyo: 120, Nagoya: 100, Osaka: 80 };
|
|
2632
3264
|
const demands = { North: 70, East: 90, West: 60, South: 60 };
|
|
2633
3265
|
|
|
2634
|
-
async function
|
|
3266
|
+
async function solveScenario(label: string, cap: typeof supplies) {
|
|
2635
3267
|
const m = new Model("TransportationNetwork");
|
|
2636
3268
|
|
|
2637
3269
|
// 1. Create 2D continuous variables: ship[factory][store] >= 0
|
|
2638
3270
|
const ship = m.continuous([...factories], [...stores], { lb: 0 });
|
|
2639
3271
|
|
|
2640
3272
|
// 2. Objective: Minimize total shipping cost across all routes via 2D dot product
|
|
2641
|
-
m.objective(minimize(dot(shippingCost, ship)));
|
|
3273
|
+
m.objective(minimize(expr`dot(${shippingCost}, ${ship})`));
|
|
2642
3274
|
|
|
2643
|
-
// 3. Supply constraints (row sums): outgoing from f <=
|
|
2644
|
-
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
`supply_${f}`
|
|
2648
|
-
)
|
|
2649
|
-
|
|
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
|
+
);
|
|
2650
3282
|
|
|
2651
3283
|
// 4. Demand constraints (column sums): incoming to s >= demands[s]
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
|
|
2656
|
-
|
|
2657
|
-
|
|
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
|
+
);
|
|
2658
3293
|
|
|
2659
3294
|
const status = await m.solve();
|
|
2660
|
-
console.log(`Status: ${status}, Total Shipping Cost: $${m.objectiveValue}`);
|
|
3295
|
+
console.log(`[${label}] Status: ${status}, Total Shipping Cost: $${m.objectiveValue}`);
|
|
2661
3296
|
for (const f of factories) {
|
|
3297
|
+
const dual = supplyConstrs[f].dual ?? 0;
|
|
2662
3298
|
const routes = stores
|
|
2663
3299
|
.map((s) => `${s}:${Math.round(ship[f][s].value ?? 0)}`)
|
|
2664
3300
|
.join(", ");
|
|
2665
|
-
console.log(` ${f.padEnd(7)} -> ${routes}`);
|
|
3301
|
+
console.log(` ${f.padEnd(7)} -> ${routes} (supply dual: $${dual}/u)`);
|
|
2666
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
|
+
});
|
|
2667
3319
|
}
|
|
2668
3320
|
|
|
2669
3321
|
runTransportation();
|
|
2670
3322
|
```
|
|
2671
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
|
+
|
|
2672
3331
|
---
|
|
2673
3332
|
|
|
2674
3333
|
# Diet Problem & Sensitivity Analysis (LP Duals & Slacks)
|
|
2675
3334
|
|
|
2676
3335
|
| Problem Class | Solver | Key APIs | Links |
|
|
2677
3336
|
| :--- | :--- | :--- | :--- |
|
|
2678
|
-
| 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> |
|
|
2679
3338
|
|
|
2680
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:
|
|
2681
3340
|
|
|
@@ -2699,7 +3358,7 @@ We choose daily servings ($0 \le x_i \le 4$) among 6 food items to meet 4 daily
|
|
|
2699
3358
|
## Implementation in WaSHI
|
|
2700
3359
|
|
|
2701
3360
|
```typescript
|
|
2702
|
-
import { Model, minimize,
|
|
3361
|
+
import { Model, minimize, expr } from "@ohtaman/washi";
|
|
2703
3362
|
|
|
2704
3363
|
const foods = [
|
|
2705
3364
|
{ name: "Oats", cost: 0.60, calories: 150, protein: 5, calcium: 20, iron: 1.8 },
|
|
@@ -2722,14 +3381,16 @@ async function runDietSensitivity() {
|
|
|
2722
3381
|
const calcs = Object.fromEntries(foods.map((f) => [f.name, f.calcium]));
|
|
2723
3382
|
const irons = Object.fromEntries(foods.map((f) => [f.name, f.iron]));
|
|
2724
3383
|
|
|
2725
|
-
// Objective: Minimize total daily cost via dot
|
|
2726
|
-
m.objective(minimize(dot(costs, srv)));
|
|
3384
|
+
// Objective: Minimize total daily cost via dot() inside expr
|
|
3385
|
+
m.objective(minimize(expr`dot(${costs}, ${srv})`));
|
|
2727
3386
|
|
|
2728
3387
|
// Nutritional constraints defined declaratively with expr and constraint()
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
|
|
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
|
+
];
|
|
2733
3394
|
|
|
2734
3395
|
const status = await m.solve();
|
|
2735
3396
|
|
|
@@ -2740,11 +3401,8 @@ async function runDietSensitivity() {
|
|
|
2740
3401
|
console.log(` ${f.name.padEnd(8)}: value=${v.value?.toFixed(2)}, reducedCost=${v.reducedCost?.toFixed(4)}`);
|
|
2741
3402
|
}
|
|
2742
3403
|
console.log("--- Nutritional Constraints (Dual & Slack) ---");
|
|
2743
|
-
for (const
|
|
2744
|
-
|
|
2745
|
-
if (c) {
|
|
2746
|
-
console.log(` ${c.name.padEnd(13)}: dual=$${c.dual?.toFixed(4)}, slack=${Math.abs(c.slack ?? 0).toFixed(2)}`);
|
|
2747
|
-
}
|
|
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)}`);
|
|
2748
3406
|
}
|
|
2749
3407
|
}
|
|
2750
3408
|
|
|
@@ -2755,7 +3413,9 @@ runDietSensitivity();
|
|
|
2755
3413
|
|
|
2756
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**.
|
|
2757
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`.
|
|
2758
|
-
- **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.
|
|
2759
3419
|
|
|
2760
3420
|
---
|
|
2761
3421
|
|
|
@@ -2763,7 +3423,7 @@ runDietSensitivity();
|
|
|
2763
3423
|
|
|
2764
3424
|
| Problem Class | Solver | Key APIs | Links |
|
|
2765
3425
|
| :--- | :--- | :--- | :--- |
|
|
2766
|
-
| 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> |
|
|
2767
3427
|
|
|
2768
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`.
|
|
2769
3429
|
|
|
@@ -2792,7 +3452,7 @@ We evaluate two distinct, named scenarios with different vacation inputs to illu
|
|
|
2792
3452
|
## Implementation in WaSHI
|
|
2793
3453
|
|
|
2794
3454
|
```typescript
|
|
2795
|
-
import { Model,
|
|
3455
|
+
import { Model, minimize, expr } from "@ohtaman/washi";
|
|
2796
3456
|
import "@ohtaman/washi/solvers/z3";
|
|
2797
3457
|
|
|
2798
3458
|
const days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] as const;
|
|
@@ -2825,12 +3485,12 @@ function buildShiftModel(name: string, dayOffRequests: Array<{ staff: string; da
|
|
|
2825
3485
|
),
|
|
2826
3486
|
])
|
|
2827
3487
|
);
|
|
2828
|
-
m.objective(minimize(dot(costMatrix, shift)));
|
|
3488
|
+
m.objective(minimize(expr`dot(${costMatrix}, ${shift})`));
|
|
2829
3489
|
|
|
2830
3490
|
// Rule 1: Daily headcount coverage
|
|
2831
3491
|
for (const d of days) {
|
|
2832
3492
|
m.constraint(
|
|
2833
|
-
expr
|
|
3493
|
+
expr`sum(${staffList}, ${(s) => shift[s.name][d]}) >= ${requiredCoverage[d]}`,
|
|
2834
3494
|
`coverage_${d}`
|
|
2835
3495
|
);
|
|
2836
3496
|
}
|
|
@@ -2839,7 +3499,7 @@ function buildShiftModel(name: string, dayOffRequests: Array<{ staff: string; da
|
|
|
2839
3499
|
const seniors = staffList.filter((s) => s.isSenior);
|
|
2840
3500
|
for (const d of days) {
|
|
2841
3501
|
m.constraint(
|
|
2842
|
-
expr
|
|
3502
|
+
expr`sum(${seniors}, ${(s) => shift[s.name][d]}) >= 1`,
|
|
2843
3503
|
`senior_duty_${d}`
|
|
2844
3504
|
);
|
|
2845
3505
|
}
|
|
@@ -2847,7 +3507,7 @@ function buildShiftModel(name: string, dayOffRequests: Array<{ staff: string; da
|
|
|
2847
3507
|
// Rule 3: Max working days per staff member
|
|
2848
3508
|
for (const s of staffList) {
|
|
2849
3509
|
m.constraint(
|
|
2850
|
-
expr
|
|
3510
|
+
expr`sum(${shift[s.name]}) <= ${s.maxShifts}`,
|
|
2851
3511
|
`max_shifts_${s.name}`
|
|
2852
3512
|
);
|
|
2853
3513
|
}
|
|
@@ -2883,7 +3543,7 @@ async function runScenario(
|
|
|
2883
3543
|
if (status === "Optimal") {
|
|
2884
3544
|
console.log(`Optimal Weekly Payroll: $${model.objectiveValue}`);
|
|
2885
3545
|
for (const s of staffList) {
|
|
2886
|
-
const schedule = days.map((d) => (
|
|
3546
|
+
const schedule = days.map((d) => (shift[s.name][d].value === 1 ? "ON" : " ·"));
|
|
2887
3547
|
console.log(` ${s.name.padEnd(8)}: ${schedule.join(" ")}`);
|
|
2888
3548
|
}
|
|
2889
3549
|
console.log("Schedule resolved successfully (no UNSAT diagnosis needed).\n");
|
|
@@ -2929,13 +3589,26 @@ async function main() {
|
|
|
2929
3589
|
main();
|
|
2930
3590
|
```
|
|
2931
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
|
+
|
|
2932
3605
|
---
|
|
2933
3606
|
|
|
2934
3607
|
# E-Commerce Cart & Coupon Optimizer (Indicator MIP)
|
|
2935
3608
|
|
|
2936
3609
|
| Problem Class | Solver | Key APIs | Links |
|
|
2937
3610
|
| :--- | :--- | :--- | :--- |
|
|
2938
|
-
| 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> |
|
|
2939
3612
|
|
|
2940
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.
|
|
2941
3614
|
|
|
@@ -2960,7 +3633,7 @@ By adding the `$10` cable (`$252` gross), the customer unlocks `MEGA45_250` (`-$
|
|
|
2960
3633
|
## Implementation in WaSHI
|
|
2961
3634
|
|
|
2962
3635
|
```typescript
|
|
2963
|
-
import { Model,
|
|
3636
|
+
import { Model, minimize, expr } from "@ohtaman/washi";
|
|
2964
3637
|
|
|
2965
3638
|
async function optimizeCheckoutCart() {
|
|
2966
3639
|
const m = new Model("CartCouponOptimizer");
|
|
@@ -2981,50 +3654,43 @@ async function optimizeCheckoutCart() {
|
|
|
2981
3654
|
|
|
2982
3655
|
// Binary decision variables for each coupon
|
|
2983
3656
|
const useCoupon = m.binary(coupons.map((c) => c.id));
|
|
3657
|
+
const discounts = Object.fromEntries(coupons.map((c) => [c.id, c.discount]));
|
|
2984
3658
|
|
|
2985
|
-
const merchSpend = addUsbCable
|
|
2986
|
-
const techSpend = addUsbCable
|
|
3659
|
+
const merchSpend = expr`${baseCartSubtotal} + ${usbCablePrice} * ${addUsbCable}`;
|
|
3660
|
+
const techSpend = expr`${baseTechSubtotal} + ${usbCablePrice} * ${addUsbCable}`;
|
|
2987
3661
|
|
|
2988
|
-
// 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!
|
|
2989
3664
|
for (const cpn of coupons) {
|
|
2990
3665
|
if (cpn.minCart > 0) {
|
|
2991
|
-
m.indicator(useCoupon[cpn.id], merchSpend
|
|
3666
|
+
m.indicator(useCoupon[cpn.id], expr`${merchSpend} >= ${cpn.minCart}`, {
|
|
2992
3667
|
activeValue: 1,
|
|
2993
|
-
M: 500,
|
|
2994
3668
|
name: `min_spend_${cpn.id}`,
|
|
2995
3669
|
});
|
|
2996
3670
|
}
|
|
2997
3671
|
if (cpn.minTech > 0) {
|
|
2998
|
-
m.indicator(useCoupon[cpn.id], techSpend
|
|
3672
|
+
m.indicator(useCoupon[cpn.id], expr`${techSpend} >= ${cpn.minTech}`, {
|
|
2999
3673
|
activeValue: 1,
|
|
3000
|
-
M: 500,
|
|
3001
3674
|
name: `min_tech_${cpn.id}`,
|
|
3002
3675
|
});
|
|
3003
3676
|
}
|
|
3004
3677
|
}
|
|
3005
3678
|
|
|
3006
3679
|
// 2. Mutual exclusion among non-stackable ("Exclusive") coupons
|
|
3007
|
-
const
|
|
3008
|
-
m.constraint(
|
|
3009
|
-
expr`${sum(exclusiveCoupons, (c) => useCoupon[c.id])} <= 1`,
|
|
3010
|
-
"at_most_one_exclusive"
|
|
3011
|
-
);
|
|
3680
|
+
const exclusiveVars = coupons.filter((c) => !c.stackable).map((c) => useCoupon[c.id]);
|
|
3681
|
+
m.constraint(expr`sum(${exclusiveVars}) <= 1`, "at_most_one_exclusive");
|
|
3012
3682
|
|
|
3013
3683
|
// 3. Max 2 coupons per order
|
|
3014
|
-
m.constraint(
|
|
3015
|
-
expr`${sum(coupons, (c) => useCoupon[c.id])} <= 2`,
|
|
3016
|
-
"max_two_coupons"
|
|
3017
|
-
);
|
|
3684
|
+
m.constraint(expr`sum(${useCoupon}) <= 2`, "max_two_coupons");
|
|
3018
3685
|
|
|
3019
3686
|
// Objective: Minimize final net payable amount (merchSpend - totalDiscounts)
|
|
3020
|
-
|
|
3021
|
-
m.objective(minimize(merchSpend.sub(totalDiscount)));
|
|
3687
|
+
m.objective(minimize(expr`${merchSpend} - dot(${discounts}, ${useCoupon})`));
|
|
3022
3688
|
|
|
3023
3689
|
const status = await m.solve();
|
|
3024
3690
|
console.log(`Status: ${status}, Optimal Net Payable: $${m.objectiveValue}`);
|
|
3025
|
-
console.log(`Recommended Add-on (+$10 USB-C Cable): ${
|
|
3691
|
+
console.log(`Recommended Add-on (+$10 USB-C Cable): ${addUsbCable.value === 1 ? "YES" : "NO"}`);
|
|
3026
3692
|
for (const cpn of coupons) {
|
|
3027
|
-
const applied =
|
|
3693
|
+
const applied = useCoupon[cpn.id].value === 1;
|
|
3028
3694
|
console.log(` ${applied ? "✅ [APPLIED]" : " [skipped]"} ${cpn.id.padEnd(11)} (-$${cpn.discount})`);
|
|
3029
3695
|
}
|
|
3030
3696
|
}
|
|
@@ -3038,7 +3704,7 @@ optimizeCheckoutCart();
|
|
|
3038
3704
|
|
|
3039
3705
|
| Problem Class | Solver | Key APIs | Links |
|
|
3040
3706
|
| :--- | :--- | :--- | :--- |
|
|
3041
|
-
| 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> |
|
|
3042
3708
|
|
|
3043
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.
|
|
3044
3710
|
|
|
@@ -3058,7 +3724,7 @@ where $\boldsymbol{\Sigma}$ is the positive semi-definite asset return covarianc
|
|
|
3058
3724
|
## Implementation in WaSHI
|
|
3059
3725
|
|
|
3060
3726
|
```typescript
|
|
3061
|
-
import { Model, QuadExpr,
|
|
3727
|
+
import { Model, QuadExpr, dot, minimize, expr } from "@ohtaman/washi";
|
|
3062
3728
|
|
|
3063
3729
|
async function solvePortfolio() {
|
|
3064
3730
|
const m = new Model("PortfolioQP");
|
|
@@ -3080,11 +3746,12 @@ async function solvePortfolio() {
|
|
|
3080
3746
|
const w = m.continuous(assets, { lb: 0, ub: 1 });
|
|
3081
3747
|
|
|
3082
3748
|
// 1. Budget constraint: sum(w_i) == 1.0 (100%)
|
|
3083
|
-
m.constraint(expr
|
|
3749
|
+
m.constraint(expr`sum(${w}) == 1.0`, "budget_limit");
|
|
3084
3750
|
|
|
3085
3751
|
// 2. Expected return constraint: sum(r_i * w_i) >= 8.5%
|
|
3086
3752
|
const retMap = Object.fromEntries(assets.map((a, i) => [a, expectedReturns[i]]));
|
|
3087
|
-
|
|
3753
|
+
const portfolioReturn = dot(retMap, w);
|
|
3754
|
+
m.constraint(expr`${portfolioReturn} >= ${targetReturn}`, "target_return");
|
|
3088
3755
|
|
|
3089
3756
|
// 3. Quadratic Objective: minimize risk w^T * Sigma * w
|
|
3090
3757
|
let risk = new QuadExpr();
|
|
@@ -3103,6 +3770,7 @@ async function solvePortfolio() {
|
|
|
3103
3770
|
console.log(`Status: ${status}`);
|
|
3104
3771
|
console.log(`Variance: ${m.objectiveValue?.toFixed(6)}`);
|
|
3105
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)}%)`);
|
|
3106
3774
|
|
|
3107
3775
|
assets.forEach((name) => {
|
|
3108
3776
|
console.log(` ${name.padEnd(10)}: ${((w[name].value ?? 0) * 100).toFixed(2)}%`);
|
|
@@ -3112,13 +3780,18 @@ async function solvePortfolio() {
|
|
|
3112
3780
|
solvePortfolio();
|
|
3113
3781
|
```
|
|
3114
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
|
+
|
|
3115
3788
|
---
|
|
3116
3789
|
|
|
3117
3790
|
# SMT Security Policies
|
|
3118
3791
|
|
|
3119
3792
|
| Problem Class | Solver | Key APIs | Links |
|
|
3120
3793
|
| :--- | :--- | :--- | :--- |
|
|
3121
|
-
| 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> |
|
|
3122
3795
|
|
|
3123
3796
|
Satisfiability Modulo Theories (SMT) combines propositional logic (Boolean SAT) with first-order theories (linear integer arithmetic, real inequalities).
|
|
3124
3797
|
|
|
@@ -3135,7 +3808,7 @@ We model a security rule system with sensor readings and logic rules:
|
|
|
3135
3808
|
## Implementation in WaSHI
|
|
3136
3809
|
|
|
3137
3810
|
```typescript
|
|
3138
|
-
import { Model, not, expr } from "@ohtaman/washi";
|
|
3811
|
+
import { Model, implies, not, expr } from "@ohtaman/washi";
|
|
3139
3812
|
import "@ohtaman/washi/solvers/z3";
|
|
3140
3813
|
|
|
3141
3814
|
async function verifySecurityRules() {
|
|
@@ -3153,7 +3826,7 @@ async function verifySecurityRules() {
|
|
|
3153
3826
|
const luxLevel = m.integer("luxLevel", { lb: 0, ub: 1000 });
|
|
3154
3827
|
|
|
3155
3828
|
// Rule 1: luxLevel <= 50 implies isNight
|
|
3156
|
-
m.constraint(luxLevel
|
|
3829
|
+
m.constraint(implies(expr`${luxLevel} <= 50`, isNight), "dark_implies_night");
|
|
3157
3830
|
|
|
3158
3831
|
// Rule 2: (motionDetected AND isNight AND alarmArmed) => sirenActive
|
|
3159
3832
|
m.constraint(
|
|
@@ -3180,20 +3853,32 @@ async function verifySecurityRules() {
|
|
|
3180
3853
|
console.log(`Lights On : ${lightsOn.value}`);
|
|
3181
3854
|
console.log(`Door Locked : ${frontDoorUnlocked.value ? "No" : "Yes"}`);
|
|
3182
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(", ")}`);
|
|
3183
3863
|
}
|
|
3184
3864
|
|
|
3185
3865
|
verifySecurityRules();
|
|
3186
3866
|
```
|
|
3187
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
|
+
|
|
3188
3873
|
---
|
|
3189
3874
|
|
|
3190
3875
|
# Conflict Diagnosis (Unsat Core)
|
|
3191
3876
|
|
|
3192
3877
|
| Problem Class | Solver | Key APIs | Links |
|
|
3193
3878
|
| :--- | :--- | :--- | :--- |
|
|
3194
|
-
| 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> |
|
|
3195
3880
|
|
|
3196
|
-
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.
|
|
3197
3882
|
|
|
3198
3883
|
This example shows how to isolate the conflicting constraint subset using Z3 Unsat Core extraction.
|
|
3199
3884
|
|
|
@@ -3213,7 +3898,7 @@ $$8 \text{ engineers} \times \$8,000 = \$64,000 > \$50,000 \text{ budget!}$$
|
|
|
3213
3898
|
## Implementation in WaSHI
|
|
3214
3899
|
|
|
3215
3900
|
```typescript
|
|
3216
|
-
import { Model, expr } from "@ohtaman/washi";
|
|
3901
|
+
import { Model, implies, expr } from "@ohtaman/washi";
|
|
3217
3902
|
import "@ohtaman/washi/solvers/z3";
|
|
3218
3903
|
|
|
3219
3904
|
async function diagnoseConflicts() {
|
|
@@ -3230,7 +3915,7 @@ async function diagnoseConflicts() {
|
|
|
3230
3915
|
m.constraint(rushDelivery, "rush_required");
|
|
3231
3916
|
|
|
3232
3917
|
// Rule 3: Rush delivery requires at least 8 engineers
|
|
3233
|
-
m.constraint(
|
|
3918
|
+
m.constraint(implies(rushDelivery, expr`${engineers} >= 8`), "rush_staffing_rule");
|
|
3234
3919
|
|
|
3235
3920
|
// Rule 4: Board budget cap of $50,000 (Conflict!)
|
|
3236
3921
|
m.constraint(expr`${budget} <= 50000`, "strict_board_budget_cap");
|
|
@@ -3242,8 +3927,381 @@ async function diagnoseConflicts() {
|
|
|
3242
3927
|
const res = await m.check({ solver: "z3" });
|
|
3243
3928
|
|
|
3244
3929
|
console.log(`Status: ${res.status}`);
|
|
3245
|
-
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);
|
|
3246
3935
|
}
|
|
3247
3936
|
|
|
3248
3937
|
diagnoseConflicts();
|
|
3249
3938
|
```
|
|
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
|
+
|
|
3946
|
+
---
|
|
3947
|
+
|
|
3948
|
+
# Capacitated Vehicle Routing Problem (CVRP, 50 Customers via Column Generation)
|
|
3949
|
+
|
|
3950
|
+
| Problem Class | Solver | Key APIs | Links |
|
|
3951
|
+
| :--- | :--- | :--- | :--- |
|
|
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> |
|
|
3953
|
+
|
|
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.
|
|
3955
|
+
|
|
3956
|
+
In a monolithic MIP formulation, enumerating all possible customer combinations and visit sequences leads to $O(2^n)$ binary variables. With **Column Generation (Dantzig-Wolfe Decomposition)**, routes are generated on demand:
|
|
3957
|
+
1. **Restricted Master Problem (RMP)**: Solves a set partitioning problem that selects a subset of candidate routes so that every customer is visited once.
|
|
3958
|
+
2. **Pricing Subproblem**: Uses the dual values (shadow prices) from the master LP relaxation to construct improving routes with negative reduced cost ($\bar{c} = 1 - \sum \pi_i < 0$) via cheapest insertion and 2-opt local search.
|
|
3959
|
+
|
|
3960
|
+
## Problem Inputs
|
|
3961
|
+
|
|
3962
|
+
We consider a distribution network with a central depot at $(0, 0)$ and **50 customer locations**:
|
|
3963
|
+
|
|
3964
|
+
- **Vehicle Cargo Capacity**: At most **10 units** per vehicle.
|
|
3965
|
+
- **Maximum Stops**: At most **4 customers** per route.
|
|
3966
|
+
- **Maximum Shift Duration**: At most **200 minutes** (transit time + service time) per route.
|
|
3967
|
+
- **Transit & Service Times**: Euclidean distances between nodes (1 unit = 1 minute) plus **10 minutes** of unloading service time per customer.
|
|
3968
|
+
|
|
3969
|
+
## Implementation in WaSHI
|
|
3970
|
+
|
|
3971
|
+
```typescript
|
|
3972
|
+
import { Model, expr, minimize } from "@ohtaman/washi";
|
|
3973
|
+
|
|
3974
|
+
// --- Extracted Routing Helpers ---
|
|
3975
|
+
|
|
3976
|
+
// 2-opt local search to eliminate crossing paths in a tour
|
|
3977
|
+
function optimize2Opt(tour, dist) {
|
|
3978
|
+
let best = [...tour];
|
|
3979
|
+
let improved = true;
|
|
3980
|
+
|
|
3981
|
+
while (improved) {
|
|
3982
|
+
improved = false;
|
|
3983
|
+
for (let i = 1; i < best.length - 2; i++) {
|
|
3984
|
+
for (let j = i + 1; j < best.length - 1; j++) {
|
|
3985
|
+
const dCurrent = dist(best[i - 1], best[i]) + dist(best[j], best[j + 1]);
|
|
3986
|
+
const dSwap = dist(best[i - 1], best[j]) + dist(best[i], best[j + 1]);
|
|
3987
|
+
if (dSwap < dCurrent) {
|
|
3988
|
+
best = [...best.slice(0, i), ...best.slice(i, j + 1).reverse(), ...best.slice(j + 1)];
|
|
3989
|
+
improved = true;
|
|
3990
|
+
}
|
|
3991
|
+
}
|
|
3992
|
+
}
|
|
3993
|
+
}
|
|
3994
|
+
return best;
|
|
3995
|
+
}
|
|
3996
|
+
|
|
3997
|
+
// Inserts a customer into the tour at the position that minimizes detour distance
|
|
3998
|
+
function insertCheapest(tour, id, dist) {
|
|
3999
|
+
let bestPos = 1;
|
|
4000
|
+
let minCost = Infinity;
|
|
4001
|
+
|
|
4002
|
+
for (let p = 1; p < tour.length; p++) {
|
|
4003
|
+
const cost = dist(tour[p - 1], id) + dist(id, tour[p]) - dist(tour[p - 1], tour[p]);
|
|
4004
|
+
if (cost < minCost) {
|
|
4005
|
+
minCost = cost;
|
|
4006
|
+
bestPos = p;
|
|
4007
|
+
}
|
|
4008
|
+
}
|
|
4009
|
+
return [...tour.slice(0, bestPos), id, ...tour.slice(bestPos)];
|
|
4010
|
+
}
|
|
4011
|
+
|
|
4012
|
+
// Calculates transit time and total duration (transit + service) for a tour
|
|
4013
|
+
function calcRouteTime(tour, dist, serviceTimes) {
|
|
4014
|
+
let travelTime = 0;
|
|
4015
|
+
for (let i = 0; i < tour.length - 1; i++) {
|
|
4016
|
+
travelTime += dist(tour[i], tour[i + 1]);
|
|
4017
|
+
}
|
|
4018
|
+
const serviceTime = tour.slice(1, -1).reduce((acc, id) => acc + (serviceTimes[id] ?? 0), 0);
|
|
4019
|
+
return { travelTime, totalTime: travelTime + serviceTime };
|
|
4020
|
+
}
|
|
4021
|
+
|
|
4022
|
+
// Pricing subproblem: Finds an improving route with negative reduced cost (1 - dualSum < 0)
|
|
4023
|
+
function findImprovingRoute(candidates, config) {
|
|
4024
|
+
let bestRoute = null;
|
|
4025
|
+
let minReducedCost = -1e-4;
|
|
4026
|
+
|
|
4027
|
+
for (const seed of candidates.slice(0, 15)) {
|
|
4028
|
+
let tour = ["Depot", seed.id, "Depot"];
|
|
4029
|
+
let load = seed.demand;
|
|
4030
|
+
let dualSum = seed.dual;
|
|
4031
|
+
|
|
4032
|
+
for (const next of candidates) {
|
|
4033
|
+
if (tour.includes(next.id)) continue;
|
|
4034
|
+
if (load + next.demand > config.vehicleCapacity) continue;
|
|
4035
|
+
if (tour.length - 2 >= config.maxStops) continue;
|
|
4036
|
+
|
|
4037
|
+
const candidateTour = optimize2Opt(insertCheapest(tour, next.id, config.dist), config.dist);
|
|
4038
|
+
const { totalTime } = calcRouteTime(candidateTour, config.dist, config.serviceTimes);
|
|
4039
|
+
|
|
4040
|
+
if (totalTime <= config.maxRouteTime) {
|
|
4041
|
+
tour = candidateTour;
|
|
4042
|
+
load += next.demand;
|
|
4043
|
+
dualSum += next.dual;
|
|
4044
|
+
}
|
|
4045
|
+
}
|
|
4046
|
+
|
|
4047
|
+
const reducedCost = 1 - dualSum;
|
|
4048
|
+
if (reducedCost < minReducedCost) {
|
|
4049
|
+
minReducedCost = reducedCost;
|
|
4050
|
+
bestRoute = tour;
|
|
4051
|
+
}
|
|
4052
|
+
}
|
|
4053
|
+
return bestRoute;
|
|
4054
|
+
}
|
|
4055
|
+
|
|
4056
|
+
// --- Main CVRP via Column Generation ---
|
|
4057
|
+
|
|
4058
|
+
async function runCVRP() {
|
|
4059
|
+
const vehicleCapacity = 10;
|
|
4060
|
+
const maxStops = 4;
|
|
4061
|
+
const maxRouteTime = 200; // minutes
|
|
4062
|
+
|
|
4063
|
+
// Central depot and 50 customer locations
|
|
4064
|
+
const depot = { id: "Depot", x: 0, y: 0 };
|
|
4065
|
+
const customers = Array.from({ length: 50 }, (_, i) => {
|
|
4066
|
+
const angle = i * 2.4;
|
|
4067
|
+
const radius = 15 + (i % 5) * 8;
|
|
4068
|
+
return {
|
|
4069
|
+
id: `C${i + 1}`,
|
|
4070
|
+
x: Math.round(Math.cos(angle) * radius),
|
|
4071
|
+
y: Math.round(Math.sin(angle) * radius),
|
|
4072
|
+
demand: 1 + (i % 4),
|
|
4073
|
+
serviceTime: 10,
|
|
4074
|
+
};
|
|
4075
|
+
});
|
|
4076
|
+
|
|
4077
|
+
const nodes = Object.fromEntries([depot, ...customers].map((n) => [n.id, n]));
|
|
4078
|
+
const dist = (a, b) =>
|
|
4079
|
+
Math.round(Math.hypot(nodes[a].x - nodes[b].x, nodes[a].y - nodes[b].y));
|
|
4080
|
+
const serviceTimes = Object.fromEntries(customers.map((c) => [c.id, c.serviceTime]));
|
|
4081
|
+
|
|
4082
|
+
const routeConfig = { dist, serviceTimes, vehicleCapacity, maxStops, maxRouteTime };
|
|
4083
|
+
|
|
4084
|
+
// 1. Build Restricted Master Problem (RMP)
|
|
4085
|
+
const master = new Model("CVRP_FleetMaster");
|
|
4086
|
+
|
|
4087
|
+
// Initial naive routes: 1 dedicated vehicle per customer
|
|
4088
|
+
const initialRoutes = customers.map((c) => master.binary(`route_init_${c.id}`));
|
|
4089
|
+
master.objective(minimize(expr`sum(${initialRoutes})`));
|
|
4090
|
+
|
|
4091
|
+
// Each customer must be visited by exactly 1 vehicle
|
|
4092
|
+
const visitConstrs = customers.map((c, i) =>
|
|
4093
|
+
master.constraint(expr`${initialRoutes[i]} == 1`, `visit_${c.id}`)
|
|
4094
|
+
);
|
|
4095
|
+
|
|
4096
|
+
console.log(`Initial state: 50 naive single-customer routes (50 vehicles).`);
|
|
4097
|
+
console.log("Solving via Column Generation with dual-directed heuristic pricing...");
|
|
4098
|
+
|
|
4099
|
+
// 2. Solve via Column Generation
|
|
4100
|
+
const result = await master.solveColumnGeneration({
|
|
4101
|
+
finalizeAsMIP: true,
|
|
4102
|
+
pricing: async () => {
|
|
4103
|
+
// 1. Sort customers by shadow price (dual profit) descending
|
|
4104
|
+
const candidates = customers
|
|
4105
|
+
.map((c, i) => ({ id: c.id, demand: c.demand, dual: visitConstrs[i].dual ?? 0 }))
|
|
4106
|
+
.filter((c) => c.dual > 0)
|
|
4107
|
+
.sort((a, b) => b.dual - a.dual);
|
|
4108
|
+
|
|
4109
|
+
// 2. Solve pricing subproblem: find route with negative reduced cost (dualSum > 1)
|
|
4110
|
+
const bestRoute = findImprovingRoute(candidates, routeConfig);
|
|
4111
|
+
if (!bestRoute) return null; // No improving column found
|
|
4112
|
+
|
|
4113
|
+
// 3. Propose new column to Restricted Master Problem
|
|
4114
|
+
const { travelTime, totalTime } = calcRouteTime(bestRoute, dist, serviceTimes);
|
|
4115
|
+
const coefficients = {};
|
|
4116
|
+
for (const id of bestRoute.slice(1, -1)) {
|
|
4117
|
+
coefficients[`visit_${id}`] = 1;
|
|
4118
|
+
}
|
|
4119
|
+
|
|
4120
|
+
return {
|
|
4121
|
+
cost: 1,
|
|
4122
|
+
type: "Binary",
|
|
4123
|
+
coefficients,
|
|
4124
|
+
meta: { tour: bestRoute, travelTime, totalTime },
|
|
4125
|
+
};
|
|
4126
|
+
},
|
|
4127
|
+
});
|
|
4128
|
+
|
|
4129
|
+
console.log(`\nStatus: ${result.status}`);
|
|
4130
|
+
console.log(`Fleet Size: ${Math.round(result.objectiveValue ?? 0)} vehicles (heuristic column generation)`);
|
|
4131
|
+
console.log(`Total Columns Generated: ${result.columnsAdded}`);
|
|
4132
|
+
|
|
4133
|
+
console.log("\nSample routes selected in fleet:");
|
|
4134
|
+
const activeRoutes = result.generatedColumns.filter((c) => c.variable.value === 1);
|
|
4135
|
+
for (const r of activeRoutes.slice(0, 5)) {
|
|
4136
|
+
const meta = r.proposal.meta;
|
|
4137
|
+
console.log(` ${meta?.tour.join(" -> ")} (Transit: ${meta?.travelTime}m, Total: ${meta?.totalTime}m)`);
|
|
4138
|
+
}
|
|
4139
|
+
}
|
|
4140
|
+
|
|
4141
|
+
runCVRP();
|
|
4142
|
+
```
|
|
4143
|
+
|
|
4144
|
+
## Notes
|
|
4145
|
+
|
|
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.
|
|
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.
|
|
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.
|
|
4152
|
+
|
|
4153
|
+
---
|
|
4154
|
+
|
|
4155
|
+
# Traveling Salesperson Problem (TSP via Benders Decomposition)
|
|
4156
|
+
|
|
4157
|
+
| Problem Class | Solver | Key APIs | Links |
|
|
4158
|
+
| :--- | :--- | :--- | :--- |
|
|
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> |
|
|
4160
|
+
|
|
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.
|
|
4162
|
+
|
|
4163
|
+
In the **Dantzig-Fulkerson-Johnson (DFJ)** formulation, preventing disconnected cycles (**subtours**) requires subtour elimination constraints for every non-empty proper subset of cities $S \subset V$:
|
|
4164
|
+
|
|
4165
|
+
$$\sum_{i \in S, j \notin S} x_{ij} \ge 1 \quad \forall S \subset V, \; 2 \le |S| \le |V| - 1$$
|
|
4166
|
+
|
|
4167
|
+
Because there are $2^n - 2$ subsets, enumerating all subtour constraints upfront is impractical as $n$ grows. With **Benders Decomposition (Lazy Constraint Separation)**, constraints are added only when violated:
|
|
4168
|
+
1. **Master Problem**: Solves the degree-assignment relaxation (each city has 1 incoming and 1 outgoing edge).
|
|
4169
|
+
2. **Separation Subproblem**: Inspects the candidate solution graph. If disconnected cycles exist, it adds a **Benders Feasibility Cut** eliminating the smallest cycle and resolves until a single connected tour is obtained.
|
|
4170
|
+
|
|
4171
|
+
## Problem Inputs
|
|
4172
|
+
|
|
4173
|
+
We consider **5 cities** on a 2D Euclidean coordinate grid:
|
|
4174
|
+
|
|
4175
|
+
| City | Coordinates $(x, y)$ |
|
|
4176
|
+
| :--- | :---: |
|
|
4177
|
+
| **City A (0)** | $(0, 0)$ |
|
|
4178
|
+
| **City B (1)** | $(1, 0)$ |
|
|
4179
|
+
| **City C (2)** | $(1, 2)$ |
|
|
4180
|
+
| **City D (3)** | $(0, 2)$ |
|
|
4181
|
+
| **City E (4)** | $(0.5, 3)$ |
|
|
4182
|
+
|
|
4183
|
+
## Implementation in WaSHI
|
|
4184
|
+
|
|
4185
|
+
```typescript
|
|
4186
|
+
import { Model, expr, minimize } from "@ohtaman/washi";
|
|
4187
|
+
|
|
4188
|
+
async function runTSP() {
|
|
4189
|
+
const cities = [
|
|
4190
|
+
{ name: "A", x: 0, y: 0 },
|
|
4191
|
+
{ name: "B", x: 1, y: 0 },
|
|
4192
|
+
{ name: "C", x: 1, y: 2 },
|
|
4193
|
+
{ name: "D", x: 0, y: 2 },
|
|
4194
|
+
{ name: "E", x: 0.5, y: 3 },
|
|
4195
|
+
];
|
|
4196
|
+
const n = cities.length;
|
|
4197
|
+
|
|
4198
|
+
function dist(i, j) {
|
|
4199
|
+
const dx = cities[i].x - cities[j].x;
|
|
4200
|
+
const dy = cities[i].y - cities[j].y;
|
|
4201
|
+
return Math.hypot(dx, dy);
|
|
4202
|
+
}
|
|
4203
|
+
|
|
4204
|
+
// 1. Master Problem: Degree assignment relaxation
|
|
4205
|
+
const master = new Model("TSP_BendersMaster");
|
|
4206
|
+
const x = {};
|
|
4207
|
+
const distMatrix = {};
|
|
4208
|
+
|
|
4209
|
+
for (let i = 0; i < n; i++) {
|
|
4210
|
+
x[i] = {};
|
|
4211
|
+
distMatrix[i] = {};
|
|
4212
|
+
for (let j = 0; j < n; j++) {
|
|
4213
|
+
if (i !== j) {
|
|
4214
|
+
x[i][j] = master.binary(`x_${i}_${j}`);
|
|
4215
|
+
distMatrix[i][j] = dist(i, j);
|
|
4216
|
+
}
|
|
4217
|
+
}
|
|
4218
|
+
}
|
|
4219
|
+
|
|
4220
|
+
// Objective: Minimize total tour distance
|
|
4221
|
+
master.objective(minimize(expr`dot(${distMatrix}, ${x})`));
|
|
4222
|
+
|
|
4223
|
+
// Each city must have 1 outgoing and 1 incoming edge
|
|
4224
|
+
for (let i = 0; i < n; i++) {
|
|
4225
|
+
master.constraint(expr`sum(${x[i]}) == 1`, `out_${i}`);
|
|
4226
|
+
master.constraint(expr`sum(${n}, ${(j) => x[j][i]}) == 1`, `in_${i}`);
|
|
4227
|
+
}
|
|
4228
|
+
|
|
4229
|
+
console.log("Solving TSP with Benders lazy subtour elimination...");
|
|
4230
|
+
|
|
4231
|
+
// 2. Solve via Benders Decomposition
|
|
4232
|
+
let cutCounter = 0;
|
|
4233
|
+
const result = await master.solveBenders({
|
|
4234
|
+
subproblem: () => {
|
|
4235
|
+
// Find active directed edges in the master solution
|
|
4236
|
+
const succ = {};
|
|
4237
|
+
for (let i = 0; i < n; i++) {
|
|
4238
|
+
for (let j = 0; j < n; j++) {
|
|
4239
|
+
if (i !== j && x[i][j].value === 1) {
|
|
4240
|
+
succ[i] = j;
|
|
4241
|
+
}
|
|
4242
|
+
}
|
|
4243
|
+
}
|
|
4244
|
+
|
|
4245
|
+
// Trace connected cycles
|
|
4246
|
+
const visited = new Set();
|
|
4247
|
+
const cycles = [];
|
|
4248
|
+
for (let i = 0; i < n; i++) {
|
|
4249
|
+
if (!visited.has(i)) {
|
|
4250
|
+
const cycle = [];
|
|
4251
|
+
let curr = i;
|
|
4252
|
+
while (!visited.has(curr)) {
|
|
4253
|
+
visited.add(curr);
|
|
4254
|
+
cycle.push(curr);
|
|
4255
|
+
curr = succ[curr];
|
|
4256
|
+
}
|
|
4257
|
+
cycles.push(cycle);
|
|
4258
|
+
}
|
|
4259
|
+
}
|
|
4260
|
+
|
|
4261
|
+
// If more than 1 cycle, subtours exist: inject Benders feasibility cuts for all cycles
|
|
4262
|
+
if (cycles.length > 1) {
|
|
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
|
+
});
|
|
4277
|
+
}
|
|
4278
|
+
|
|
4279
|
+
return null; // Single connected tour found
|
|
4280
|
+
},
|
|
4281
|
+
});
|
|
4282
|
+
|
|
4283
|
+
console.log(`\nStatus: ${result.status}`);
|
|
4284
|
+
console.log(`Optimal Tour Distance: ${result.objectiveValue?.toFixed(3)}`);
|
|
4285
|
+
console.log(`Total Benders Cuts Added: ${result.cutsAdded}`);
|
|
4286
|
+
console.log(`Iterations: ${result.iterations}`);
|
|
4287
|
+
|
|
4288
|
+
console.log("\nOptimal tour edges:");
|
|
4289
|
+
for (let i = 0; i < n; i++) {
|
|
4290
|
+
for (let j = 0; j < n; j++) {
|
|
4291
|
+
if (i !== j && result.values[`x_${i}_${j}`] === 1) {
|
|
4292
|
+
console.log(` ${cities[i].name} -> ${cities[j].name} (distance: ${dist(i, j).toFixed(3)})`);
|
|
4293
|
+
}
|
|
4294
|
+
}
|
|
4295
|
+
}
|
|
4296
|
+
}
|
|
4297
|
+
|
|
4298
|
+
runTSP();
|
|
4299
|
+
```
|
|
4300
|
+
|
|
4301
|
+
## Notes
|
|
4302
|
+
|
|
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.
|
|
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.
|
|
4307
|
+
- **Termination Condition**: Returning `null` from `subproblem` indicates that no violated subtour constraints remain.
|