@ohtaman/washi 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ja.md +149 -121
- package/README.md +149 -121
- package/README.zh-CN.md +150 -122
- package/THIRD_PARTY_LICENSES.md +3 -4
- package/dist/browser/_washi-node-only-BvRk9kiK.js +0 -0
- package/dist/browser/browser-runtime-TLCY0hkc.js +1793 -0
- package/dist/browser/highs-worker.js +21 -3
- 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 +433 -286
- package/dist/browser/solvers/glpk.min.js +1 -1
- package/dist/browser/solvers/z3.js +1008 -981
- package/dist/browser/solvers/z3.min.js +2 -2
- package/dist/browser/washi.js +2778 -673
- 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 +21 -3
- 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-C20sxTwm.js +964 -0
- package/dist/lib/solvers/glpk.js +201 -90
- package/dist/lib/solvers/z3.js +399 -259
- package/dist/lib/washi.js +2363 -1056
- 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 +5 -4
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/modeler/big-m.d.ts +75 -0
- package/dist/types/modeler/big-m.d.ts.map +1 -0
- package/dist/types/modeler/boolean-expr.d.ts +103 -14
- package/dist/types/modeler/boolean-expr.d.ts.map +1 -1
- package/dist/types/modeler/constraint.d.ts +47 -9
- package/dist/types/modeler/constraint.d.ts.map +1 -1
- package/dist/types/modeler/expression.d.ts +123 -28
- package/dist/types/modeler/expression.d.ts.map +1 -1
- package/dist/types/modeler/index.d.ts +11 -6
- 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 +30 -3
- package/dist/types/modeler/lp-writer.d.ts.map +1 -1
- package/dist/types/modeler/model.d.ts +623 -131
- package/dist/types/modeler/model.d.ts.map +1 -1
- package/dist/types/modeler/objective.d.ts +59 -0
- package/dist/types/modeler/objective.d.ts.map +1 -0
- package/dist/types/modeler/parser.d.ts +34 -10
- package/dist/types/modeler/parser.d.ts.map +1 -1
- package/dist/types/modeler/quad-expr.d.ts +26 -7
- package/dist/types/modeler/quad-expr.d.ts.map +1 -1
- package/dist/types/modeler/solution-validator.d.ts +93 -0
- package/dist/types/modeler/solution-validator.d.ts.map +1 -0
- package/dist/types/modeler/solvers/glpk/solver.d.ts +26 -2
- 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 +38 -13
- 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 +12 -5
- 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/mock.d.ts +14 -5
- package/dist/types/modeler/solvers/mock.d.ts.map +1 -1
- package/dist/types/modeler/solvers/types.d.ts +73 -11
- package/dist/types/modeler/solvers/types.d.ts.map +1 -1
- package/dist/types/modeler/solvers/z3/loader.d.ts +2 -3
- package/dist/types/modeler/solvers/z3/loader.d.ts.map +1 -1
- package/dist/types/modeler/solvers/z3/solver.d.ts +15 -5
- package/dist/types/modeler/solvers/z3/solver.d.ts.map +1 -1
- package/dist/types/modeler/types.d.ts +86 -1
- package/dist/types/modeler/types.d.ts.map +1 -1
- package/dist/types/modeler/validation.d.ts +56 -0
- package/dist/types/modeler/validation.d.ts.map +1 -0
- package/dist/types/modeler/variable.d.ts +48 -14
- package/dist/types/modeler/variable.d.ts.map +1 -1
- package/docs/examples/diet-sensitivity.md +90 -0
- package/docs/examples/ecommerce-coupons.md +106 -0
- package/docs/examples/index.md +57 -0
- package/docs/examples/knapsack.md +60 -0
- package/docs/examples/portfolio-qp.md +83 -0
- package/docs/examples/production.md +51 -0
- package/docs/examples/shift-scheduling.md +175 -0
- package/docs/examples/smt-security.md +74 -0
- package/docs/examples/transportation.md +90 -0
- package/docs/examples/traveling-salesperson.md +173 -0
- package/docs/examples/unsat-core.md +64 -0
- package/docs/examples/vehicle-routing.md +206 -0
- package/docs/getting-started/ai-agents.md +271 -0
- package/docs/getting-started/cross-origin.md +87 -0
- package/docs/getting-started/index.md +116 -0
- package/docs/getting-started/runtimes.md +227 -0
- package/docs/guide/constraints.md +177 -0
- package/docs/guide/decomposition.md +399 -0
- package/docs/guide/expressions.md +204 -0
- package/docs/guide/objectives.md +170 -0
- package/docs/guide/unsat-core.md +154 -0
- package/docs/guide/variables.md +188 -0
- package/docs/ja/getting-started/ai-agents.md +270 -0
- package/docs/solvers/custom-solver.md +525 -0
- package/docs/solvers/glpk.md +66 -0
- package/docs/solvers/highs.md +115 -0
- package/docs/solvers/index.md +42 -0
- package/docs/solvers/z3.md +78 -0
- package/docs/zh/getting-started/ai-agents.md +270 -0
- package/llms-full.txt +4065 -0
- package/llms.txt +201 -0
- package/package.json +32 -16
- package/skills/washi/SKILL.md +197 -0
- package/dist/browser/browser-runtime-D13gHOT5.js +0 -4189
- package/dist/browser/loader-CVt4CloI.js +0 -58
- package/dist/lib/async-mutex-Duo4jwzn.js +0 -195
- package/dist/lib/constraint-BRuB3ooC.js +0 -90
- package/dist/lib/loader-Dti0v9yC.js +0 -50
- package/dist/lib/registry-DksqCSjI.js +0 -32
package/llms-full.txt
ADDED
|
@@ -0,0 +1,4065 @@
|
|
|
1
|
+
# AI Coding Agent Quick-Reference Guide
|
|
2
|
+
|
|
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
|
+
> - **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
|
+
> ```bash
|
|
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
|
|
10
|
+
> mkdir -p .agents/skills/washi
|
|
11
|
+
> cp node_modules/@ohtaman/washi/skills/washi/SKILL.md .agents/skills/washi/SKILL.md
|
|
12
|
+
> ```
|
|
13
|
+
> - **Repository Review Guidelines**: For repository contribution and pull-request review rules, see [`AGENTS.md`](https://github.com/ohtaman/washi/blob/main/AGENTS.md) in the repository root.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## 1. Package Imports & Deployment
|
|
18
|
+
|
|
19
|
+
### Node.js / Bundlers (Vite, Webpack, Next.js, Bun)
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
import {
|
|
23
|
+
Model,
|
|
24
|
+
expr,
|
|
25
|
+
sum,
|
|
26
|
+
dot,
|
|
27
|
+
maximize,
|
|
28
|
+
minimize,
|
|
29
|
+
and,
|
|
30
|
+
or,
|
|
31
|
+
not,
|
|
32
|
+
implies,
|
|
33
|
+
xor,
|
|
34
|
+
setHighsWasmUrl,
|
|
35
|
+
} from "@ohtaman/washi";
|
|
36
|
+
|
|
37
|
+
// Optional solver plugins (require installing peer dependencies first):
|
|
38
|
+
// npm install z3-solver
|
|
39
|
+
import "@ohtaman/washi/solvers/z3";
|
|
40
|
+
// npm install glpk.js
|
|
41
|
+
import "@ohtaman/washi/solvers/glpk";
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Browser ESM & Classic Script Tags
|
|
45
|
+
|
|
46
|
+
- **Browser ESM**:
|
|
47
|
+
- Core (HiGHS built-in): `import { Model, expr, maximize } from "./washi/washi.js";`
|
|
48
|
+
- GLPK plugin: `import "./washi/solvers/glpk.js";`
|
|
49
|
+
- Z3 plugin: `import "./washi/solvers/z3.js";`
|
|
50
|
+
- **Classic `<script>` / CodePen (IIFE)**:
|
|
51
|
+
- Core: `<script src="./washi/washi.min.js"></script>` (exposes global `Washi`)
|
|
52
|
+
- GLPK plugin: `<script src="./washi/solvers/glpk.min.js"></script>` (registers `"glpk"`, exposes `WashiGLPK`)
|
|
53
|
+
- Z3 plugin: `<script src="./washi/solvers/z3.min.js"></script>` (registers `"z3"`, exposes `WashiZ3`)
|
|
54
|
+
|
|
55
|
+
### WebAssembly Asset & Runtime Rules
|
|
56
|
+
|
|
57
|
+
1. **Bundled WASM by default (no implicit CDN fallback)**:
|
|
58
|
+
- In Node.js and modern bundlers, WebAssembly assets resolve relative to the module automatically.
|
|
59
|
+
- In browser deployments, deploy the full `dist/browser/` directory (JS, Worker, and `.wasm` files) together. If a local WASM URL is missing or returns an HTML SPA fallback, initialization fails immediately and reports the attempted URL—it **never** silently switches to a public CDN.
|
|
60
|
+
- In browser runtimes, to load `highs.wasm` from a custom location, call `setHighsWasmUrl("https://.../highs.wasm")` or pass `await m.solve({ wasmUrl: "https://.../highs.wasm" })` (ignored in Node.js, which always loads the local Node `highs` package runtime).
|
|
61
|
+
2. **Z3 requires a Secure Context, Cross-Origin Isolation, and CORS in browsers**:
|
|
62
|
+
- Z3 WebAssembly uses `SharedArrayBuffer` multithreading. Browser pages running Z3 must run in a secure context (`window.isSecureContext === true`, i.e., HTTPS or `localhost`) and be cross-origin isolated (`window.crossOriginIsolated === true`) by serving:
|
|
63
|
+
- `Cross-Origin-Opener-Policy: same-origin` (on the document)
|
|
64
|
+
- `Cross-Origin-Embedder-Policy: require-corp` (on the document)
|
|
65
|
+
- `Cross-Origin-Resource-Policy: cross-origin` and `Access-Control-Allow-Origin` (on cross-origin script/WASM asset responses)
|
|
66
|
+
- Importing `@ohtaman/washi` never auto-injects a Service Worker. When a secure context or cross-origin isolation is absent in a browser, Z3 rejects immediately with a descriptive error.
|
|
67
|
+
3. **Web Workers & Cancellation (`AbortSignal`)**:
|
|
68
|
+
- Pre-solve cancellation (`signal.aborted` before execution starts) is supported across all solvers.
|
|
69
|
+
- In browsers where `Worker` is available, HiGHS runs in a dedicated Web Worker by default (`useWorker: true`). Passing `{ signal }` to `m.solve({ signal })` preemptively terminates an in-flight Worker solve (or cancels queued Worker requests).
|
|
70
|
+
- On main-thread HiGHS (`useWorker: false` or in Node.js), cooperative mid-solve cancellation is supported when `abort()` is triggered synchronously inside an `onProgress` callback at a solver interrupt checkpoint; arbitrary asynchronous aborts from outside cannot preempt a blocked synchronous WASM thread until a callback checkpoint or completion. Single-threaded WASM solvers without interrupt hooks (GLPK, Z3) check `signal` before execution starts.
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## 2. Canonical Modeling Workflow
|
|
75
|
+
|
|
76
|
+
Formulating and solving a model consists of four distinct steps:
|
|
77
|
+
|
|
78
|
+
### Step 1: Create Model & Decision Variables
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
const m = new Model("ExampleModel");
|
|
82
|
+
|
|
83
|
+
// Scalar variables
|
|
84
|
+
const x = m.continuous("x", { lb: 0, ub: 100 });
|
|
85
|
+
const n = m.integer("n", { lb: 0, ub: 10 });
|
|
86
|
+
const b = m.binary("b");
|
|
87
|
+
const flag = m.bool("flag"); // Propositional boolean (for Z3 / logical constraints)
|
|
88
|
+
|
|
89
|
+
// Indexed variable containers:
|
|
90
|
+
// - Numeric dimension -> Array
|
|
91
|
+
// - Key-array dimension -> Record
|
|
92
|
+
const vec = m.continuous(4, { lb: 0, prefix: "v" }); // Var[] (length 4)
|
|
93
|
+
const grid = m.binary(2, 3, { prefix: "g" }); // Var[][] (2x3)
|
|
94
|
+
const byItem = m.integer(["desk", "chair"] as const, { lb: 0 }); // Record<"desk" | "chair", Var>
|
|
95
|
+
const mixed = m.binary(["A", "B"] as const, 3); // Record<"A" | "B", Var[]>
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
- **Optional Primal Initial Values (`initialValue?: number`)**:
|
|
99
|
+
- Numeric variable constructors (`m.continuous`, `m.integer`, `m.binary`, and `new Var`) and the mutable `v.initialValue` property accept an optional finite primal hint (validated with tolerance `1e-6` against bounds `[lb, ub]` and integer/binary domains), kept distinct from the solved `v.value`.
|
|
100
|
+
- A start may be complete or partial (e.g., transferred by stable variable names when rebuilding a MIP model). Supplying `initialValue` is a hint only—it does **not** fix the variable, add a hard constraint, or guarantee solver acceptance or a runtime speedup.
|
|
101
|
+
- Supported by **HiGHS** (`supportsWarmStart: true`); solvers without warm-start support (**GLPK**, **Z3**) throw an error if any variable has `initialValue !== undefined`.
|
|
102
|
+
|
|
103
|
+
### Step 2: Build Expressions (`expr`, `sum`, `dot`)
|
|
104
|
+
|
|
105
|
+
- **Unified `expr` template tag**:
|
|
106
|
+
- Linear expression (`LinExpr`): `` expr`30 * ${x} + 50 * ${n} - 10` ``
|
|
107
|
+
- Quadratic expression (`QuadExpr`): `` expr`${x}^2 + 2 * ${x} * ${n} + ${n}^2` `` (variable-by-variable products and squares are supported for convex QP objectives).
|
|
108
|
+
- Linear constraint (`Constraint`): `` expr`${x} + 2 * ${n} <= 40` `` (supports `<=`, `>=`, `==`, and `=`).
|
|
109
|
+
- **Aggregations**:
|
|
110
|
+
- `sum(iterable, mapper?)` sums an `Iterable` of `Var` / `LinExpr` / `number` (e.g., `Var[]`, `grid.flat()`, or `Object.values(record)`).
|
|
111
|
+
- `dot(a, b)` computes the inner product of matching 1D arrays, 2D arrays (`number[][]` / `Var[][]`), plain records (`Record<string, ...>`), or mixed Array/Record containers in either operand order.
|
|
112
|
+
- **Fluent methods**:
|
|
113
|
+
- Arithmetic (on `LinExpr` and `QuadExpr`; `Var` supports `.add()`, `.sub()`, `.mul()`, `.neg()`): `.add(other)`, `.sub(other)`, `.mul(other)`, `.div(scalar)`, `.neg()`
|
|
114
|
+
- Comparisons (on `Var` and `LinExpr`): `.le(rhs, name?)`, `.ge(rhs, name?)`, `.eq(rhs, name?)`
|
|
115
|
+
- Boolean logic (on `BoolVar`, `BoolExpr`, and `Constraint` for Z3): standalone `and(...)`, `or(...)`, `not(...)`, `implies(a, b)`, `xor(a, b)` and fluent `.and(...)`, `.or(...)`, `.not()`, `.implies(...)`, `.xor(...)`
|
|
116
|
+
|
|
117
|
+
### Step 3: Register Objective & Constraints on the Model
|
|
118
|
+
|
|
119
|
+
Constructing an `expr` or `.le()` object does **not** attach it to the model until you pass it to `m.constraint(...)` or `m.objective(...)`:
|
|
120
|
+
|
|
121
|
+
```ts
|
|
122
|
+
// Set objective using maximize() or minimize()
|
|
123
|
+
m.objective(maximize(expr`30 * ${x} + 50 * ${n}`));
|
|
124
|
+
|
|
125
|
+
// Register constraints on the model
|
|
126
|
+
m.constraint(expr`${x} + 2 * ${n} <= 40`, "resource_limit");
|
|
127
|
+
|
|
128
|
+
// Indicator constraint (MIP Big-M reformulation: b === 1 => x <= 50)
|
|
129
|
+
m.indicator(b, expr`${x} <= 50`, { activeValue: 1, name: "cap_when_b" });
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
### Step 4: Execute (`solve` or `check`) & Verify (`validateSolution`)
|
|
133
|
+
|
|
134
|
+
- **Optimization (`await m.solve(options?)`)**:
|
|
135
|
+
- Returns `SolverStatus` (`"Optimal"`, `"Feasible"`, `"Infeasible"`, `"Unbounded"`, `"TimeLimit"`, `"IterationLimit"`, `"Error"`, `"Unknown"`, `"NotSolved"`, `"Sat"`, or `"Unsat"`). Built-in optimization solvers return the optimization statuses (`"Optimal"`, `"Feasible"`, `"Infeasible"`, `"Unbounded"`, `"TimeLimit"`, `"IterationLimit"`, `"Error"`, `"Unknown"`, `"NotSolved"`), where a time-, iteration-, or solution-limited run (or an interrupted solve) that found a valid primal incumbent returns `"Feasible"`; `"Sat"` and `"Unsat"` are included in `SolverStatus` for satisfiability/custom solver adapters (such as `MockSolver`).
|
|
136
|
+
- Read values via `x.value`, `expr`...`.value`, `m.getValues(x)`, `m.getValues(container)`, `m.objectiveValue`, `constr.dual`, `constr.slack`, and `x.reducedCost`.
|
|
137
|
+
- Call `m.validateSolution()` after solving to independently verify variable finiteness, variable bounds (`lb`/`ub`), integer/binary/boolean domains, linear constraint residuals, and logical constraints (`SolutionValidationReport`: `{ valid, complete, maxViolation, violations, ... }`). Note that `validateSolution()` checks primal feasibility of variables and constraints; it does not recompute or compare `m.objectiveValue`.
|
|
138
|
+
- **Satisfiability & Conflict Diagnosis (`await m.check(options?)`)**:
|
|
139
|
+
- Returns `CheckResult` (`{ status: "Sat" | "Unsat" | "Unknown", values: Record<string, number | boolean>, conflicts?: readonly string[], unsatCore?: readonly string[], runTime?: number }`), where `conflicts` is populated when the model is proven `"Unsat"`.
|
|
140
|
+
- For models containing linear arithmetic constraints or logical constraints (`BoolVar`, `and`, `or`, `implies`, `xor`), `m.check()` works with any solver (including the default HiGHS and GLPK via single-solve elastic slack relaxation, automatically linearizing logical constraints into 0-1 MIP formulation) and diagnoses conflicting constraints as `conflicts` (leaving `unsatCore: undefined` for semantic rigor).
|
|
141
|
+
- Exact SMT unsatisfiable-core extraction (`result.unsatCore` and `result.conflicts`) is provided when using an SMT-capable solver (`{ solver: "z3" }`). Because Z3 asserts variable bounds (`lb`/`ub`) and binary `{0, 1}` domains directly while tracking registered constraints, `unsatCore` reports a subset of registered constraint names that conflicts *together with the model's fixed variable bounds/domains* (and is not guaranteed minimal).
|
|
142
|
+
- **No concurrent mutation during async solve/check**:
|
|
143
|
+
- Mutating variables, constraints, or the objective while `await m.solve()` or `await m.check()` is pending invalidates the in-flight execution and rejects the stale promise.
|
|
144
|
+
|
|
145
|
+
---
|
|
146
|
+
|
|
147
|
+
## 3. Executable Examples
|
|
148
|
+
|
|
149
|
+
### Example A: Mixed-Integer Linear Programming (MIP) with Indexed Containers & Solution Validation
|
|
150
|
+
|
|
151
|
+
```ts
|
|
152
|
+
import { Model, expr, dot, sum, maximize } from "@ohtaman/washi";
|
|
153
|
+
|
|
154
|
+
export async function runMipExample() {
|
|
155
|
+
const m = new Model("ProductionMIP");
|
|
156
|
+
const products = ["desk", "chair"] as const;
|
|
157
|
+
const profit = { desk: 30, chair: 50 };
|
|
158
|
+
const woodPerUnit = { desk: 1, chair: 2 };
|
|
159
|
+
const laborPerUnit = { desk: 2, chair: 1 };
|
|
160
|
+
|
|
161
|
+
const qty = m.integer(products, { lb: 0, ub: 100 });
|
|
162
|
+
const setup = m.binary("setup");
|
|
163
|
+
|
|
164
|
+
m.objective(maximize(expr`${dot(profit, qty)} - 20 * ${setup}`));
|
|
165
|
+
m.constraint(expr`${dot(woodPerUnit, qty)} <= 40`, "wood_cap");
|
|
166
|
+
m.constraint(expr`${dot(laborPerUnit, qty)} <= 50`, "labor_cap");
|
|
167
|
+
m.constraint(expr`${sum(Object.values(qty))} <= 30 * ${setup}`, "require_setup");
|
|
168
|
+
|
|
169
|
+
const status = await m.solve();
|
|
170
|
+
const report = m.validateSolution();
|
|
171
|
+
const values = m.getValues(qty);
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
status,
|
|
175
|
+
objectiveValue: m.objectiveValue,
|
|
176
|
+
values,
|
|
177
|
+
setup: setup.value,
|
|
178
|
+
valid: report.valid,
|
|
179
|
+
maxViolation: report.maxViolation,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
### Example B: Convex Quadratic Programming (QP)
|
|
185
|
+
|
|
186
|
+
Variable-by-variable products (`${x} * ${y}`) and squares (`${x}^2`) are supported in `expr` for quadratic objectives:
|
|
187
|
+
|
|
188
|
+
```ts
|
|
189
|
+
import { Model, expr, minimize } from "@ohtaman/washi";
|
|
190
|
+
|
|
191
|
+
export async function runQpExample() {
|
|
192
|
+
const m = new Model("PortfolioQP");
|
|
193
|
+
const x = m.continuous("x", { lb: 0 });
|
|
194
|
+
const y = m.continuous("y", { lb: 0 });
|
|
195
|
+
|
|
196
|
+
// Minimize x^2 + y^2 subject to x + y >= 4
|
|
197
|
+
m.objective(minimize(expr`${x}^2 + ${y}^2`));
|
|
198
|
+
m.constraint(expr`${x} + ${y} >= 4`, "budget");
|
|
199
|
+
|
|
200
|
+
const status = await m.solve();
|
|
201
|
+
const report = m.validateSolution();
|
|
202
|
+
|
|
203
|
+
return {
|
|
204
|
+
status,
|
|
205
|
+
objectiveValue: m.objectiveValue,
|
|
206
|
+
x: x.value,
|
|
207
|
+
y: y.value,
|
|
208
|
+
valid: report.valid,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
### Example C: Z3 Feasibility Check & Unsat Core Diagnosis
|
|
214
|
+
|
|
215
|
+
```ts
|
|
216
|
+
import { Model, expr, implies } from "@ohtaman/washi";
|
|
217
|
+
import "@ohtaman/washi/solvers/z3";
|
|
218
|
+
|
|
219
|
+
export async function runZ3Example() {
|
|
220
|
+
const m = new Model("PolicyCheck", { solver: "z3" });
|
|
221
|
+
const vip = m.bool("vip");
|
|
222
|
+
const discount = m.integer("discount", { lb: 0, ub: 50 });
|
|
223
|
+
|
|
224
|
+
m.constraint(vip, "customer_is_vip");
|
|
225
|
+
m.constraint(implies(vip, expr`${discount} >= 20`), "vip_min_discount");
|
|
226
|
+
m.constraint(expr`${discount} <= 10`, "campaign_cap");
|
|
227
|
+
|
|
228
|
+
const unsatResult = await m.check();
|
|
229
|
+
return {
|
|
230
|
+
status: unsatResult.status,
|
|
231
|
+
conflicts: unsatResult.conflicts,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
---
|
|
237
|
+
|
|
238
|
+
## 4. Negative Examples & Common Pitfalls
|
|
239
|
+
|
|
240
|
+
Avoid these invalid patterns and retired aliases:
|
|
241
|
+
|
|
242
|
+
```ts
|
|
243
|
+
import { Model, expr, maximize } from "@ohtaman/washi";
|
|
244
|
+
|
|
245
|
+
export function verifyNegativePatterns() {
|
|
246
|
+
const m = new Model("NegativeExamples");
|
|
247
|
+
const x = m.continuous("x", { lb: 0 });
|
|
248
|
+
const y = m.continuous("y", { lb: 0 });
|
|
249
|
+
|
|
250
|
+
// 1. DO NOT pass a Constraint to m.objective() or arithmetic methods:
|
|
251
|
+
// WRONG: m.objective(maximize(expr`${x} <= 10` as any)); -> throws Error
|
|
252
|
+
// WRONG: expr`${x} + 1`.add(expr`${y} <= 5` as any); -> throws Error
|
|
253
|
+
|
|
254
|
+
// 2. DO NOT pass an arithmetic expression without (<=, >=, ==) to m.constraint():
|
|
255
|
+
// WRONG: m.constraint(expr`${x} + ${y}` as any); -> throws Error
|
|
256
|
+
|
|
257
|
+
// 3. DO NOT use quadratic terms inside linear constraints:
|
|
258
|
+
// WRONG: expr`${x}^2 <= 10`; -> throws Error ("Quadratic constraints are not supported")
|
|
259
|
+
|
|
260
|
+
// 4. DO NOT use retired aliases (removed in #59 / #81):
|
|
261
|
+
// - Use `expr` instead of retired `c` template tag
|
|
262
|
+
// - Use `m.constraint(...)` instead of `m.constrain(...)` or `m.add(...)`
|
|
263
|
+
// - Use `m.objective(maximize(...))` / `m.objective(minimize(...))` instead of `m.maximize(...)` / `m.minimize(...)` / `m.setObjective(...)`
|
|
264
|
+
// - Use `await m.solve()` instead of `await m.optimize()`
|
|
265
|
+
// - Use `.le()`, `.ge()`, `.eq()` instead of `.lessThanOrEqual()`, `.greaterThanOrEqual()`, `.equalTo()`
|
|
266
|
+
// - Use `.value` or `m.getValues(...)` instead of `expr.getValue()`
|
|
267
|
+
// - Use `m.variables` / `m.constraints` instead of `m.vars` / `m.constrs`
|
|
268
|
+
return { m, x, y, maximize };
|
|
269
|
+
}
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
---
|
|
273
|
+
|
|
274
|
+
# Introduction & Installation
|
|
275
|
+
|
|
276
|
+
Welcome to **WaSHI** (WebAssembly-backed, Solver-independent, High-level modeling Interface).
|
|
277
|
+
|
|
278
|
+
WaSHI brings the algebraic modeling style of Python's **PuLP** and Julia's **JuMP** to the TypeScript and JavaScript ecosystem. You can formulate mathematical optimization problems using algebraic expressions and solve them locally using WebAssembly solvers (**HiGHS**, **Z3**, and **GLPK**)—without a separate Python service or native C/C++ build steps.
|
|
279
|
+
|
|
280
|
+
### Why TypeScript & WebAssembly?
|
|
281
|
+
|
|
282
|
+
- **Removing the deployment bottleneck**: In traditional optimization workflows (e.g., Python with PuLP or Pyomo), turning a model into an interactive application requires provisioning a backend service and managing native C/C++ solver binaries. As AI-assisted development accelerates UI and application prototyping, backend infrastructure often becomes the primary bottleneck to shipping. Running both the modeler and WebAssembly solvers on standard Web technologies allows an optimization tool to be deployed as static assets (e.g., on GitHub Pages or Cloudflare Pages, serving the generated JS, Worker, and WASM files; with COOP/COEP headers when using Z3)—and makes it straightforward to pair mathematical optimization with in-browser LLMs (e.g., WebGPU / WebLLM) entirely on the client.
|
|
283
|
+
- **Algebraic syntax without operator overloading (`expr`)**: Because JavaScript lacks operator overloading, libraries usually trade off between verbose method chaining (`x.mul(30).add(y.mul(50))`) and untyped strings (`"30 * x + 50 * y"`). WaSHI uses tagged template literals (`` expr`30 * ${x} + 50 * ${y} <= 40` ``) similar to parameterized SQL queries—parsing infix operators while binding `${x}` directly as typed TypeScript object references for static type checking, IDE symbol renaming, and multi-dimensional indexing (`${flow[i][j]}`).
|
|
284
|
+
|
|
285
|
+
---
|
|
286
|
+
|
|
287
|
+
## 📦 Installation
|
|
288
|
+
|
|
289
|
+
Install WaSHI using your preferred package manager:
|
|
290
|
+
|
|
291
|
+
```bash [npm]
|
|
292
|
+
npm install @ohtaman/washi
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
```bash [pnpm]
|
|
296
|
+
pnpm add @ohtaman/washi
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
```bash [yarn]
|
|
300
|
+
yarn add @ohtaman/washi
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
```bash [bun]
|
|
304
|
+
bun add @ohtaman/washi
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
### Optional Solver Dependencies
|
|
308
|
+
|
|
309
|
+
WaSHI ships with **HiGHS** built-in with zero configuration. If you need SMT logic / Unsat Core diagnosis via **Z3**, or compatibility with **GLPK**, install their respective peer dependencies:
|
|
310
|
+
|
|
311
|
+
```bash
|
|
312
|
+
# For SMT logic and Unsat Core conflict diagnosis
|
|
313
|
+
npm install z3-solver
|
|
314
|
+
|
|
315
|
+
# For GLPK solver (GPL-3.0)
|
|
316
|
+
npm install glpk.js
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
---
|
|
320
|
+
|
|
321
|
+
## 🚀 Quickstart Walkthrough
|
|
322
|
+
|
|
323
|
+
Here is a step-by-step example of solving a production allocation problem.
|
|
324
|
+
|
|
325
|
+
### 1. Import the Core Classes
|
|
326
|
+
|
|
327
|
+
```typescript
|
|
328
|
+
import { Model, expr, maximize } from "@ohtaman/washi";
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
### 2. Instantiate a Model
|
|
332
|
+
|
|
333
|
+
```typescript
|
|
334
|
+
const model = new Model("WoodworkingProfit");
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
### 3. Add Decision Variables
|
|
338
|
+
|
|
339
|
+
Variables represent the unknowns you want to determine. Factory methods (`m.continuous`, `m.integer`, `m.binary`) clearly define their domain:
|
|
340
|
+
|
|
341
|
+
```typescript
|
|
342
|
+
const x = model.integer("x", { lb: 0 });
|
|
343
|
+
const y = model.integer("y", { lb: 0 });
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
### 4. Set the Objective Function
|
|
347
|
+
|
|
348
|
+
Use the `expr` tagged template literal along with `maximize(...)` or `minimize(...)` to define the goal:
|
|
349
|
+
|
|
350
|
+
```typescript
|
|
351
|
+
// Maximize 30*x + 50*y
|
|
352
|
+
model.objective(maximize(expr`30 * ${x} + 50 * ${y}`));
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
### 5. Add Constraints
|
|
356
|
+
|
|
357
|
+
Use `model.constraint()` with the `expr` tagged template to formulate equality and inequality constraints:
|
|
358
|
+
|
|
359
|
+
```typescript
|
|
360
|
+
model.constraint(expr`${x} + 2 * ${y} <= 40`, "wood_capacity");
|
|
361
|
+
model.constraint(expr`2 * ${x} + ${y} <= 50`, "labor_capacity");
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
### 6. Solve and Extract Results
|
|
365
|
+
|
|
366
|
+
Solve the model asynchronously via WebAssembly (HiGHS by default):
|
|
367
|
+
|
|
368
|
+
```typescript
|
|
369
|
+
const status = await model.solve();
|
|
370
|
+
|
|
371
|
+
console.log("Status:", status); // "Optimal"
|
|
372
|
+
console.log("Optimal Objective Value:", model.objectiveValue); // 1100
|
|
373
|
+
console.log(`Solution: x = ${x.value}, y = ${y.value}`); // x = 20, y = 10
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
---
|
|
377
|
+
|
|
378
|
+
## 🧭 Next Steps
|
|
379
|
+
|
|
380
|
+
- Give AI coding agents a concise API & deployment reference with the [AI Coding Agent Quick-Reference Guide](docs/getting-started/ai-agents.md).
|
|
381
|
+
- Learn how to run WaSHI across different environments in [Runtime Environments](docs/getting-started/runtimes.md).
|
|
382
|
+
- Understand how to formulate complex expressions in the [Modeling Guide](docs/guide/expressions.md).
|
|
383
|
+
- Explore built-in and pluggable solvers in the [Solvers Matrix](docs/solvers/index.md).
|
|
384
|
+
- Browse 9 runnable optimization and SMT recipes in [Examples & API Finder](docs/examples/index.md).
|
|
385
|
+
|
|
386
|
+
---
|
|
387
|
+
|
|
388
|
+
# Runtime Environments & Deployment
|
|
389
|
+
|
|
390
|
+
WaSHI runs across JavaScript and TypeScript runtimes—from Node.js, Bun, and Deno to modern bundlers, vanilla browser `<script>` tags, and air-gapped intranets—without native C/C++ compilation.
|
|
391
|
+
|
|
392
|
+
---
|
|
393
|
+
|
|
394
|
+
## 🟢 Node.js
|
|
395
|
+
|
|
396
|
+
WaSHI supports Node.js (v18+) out of the box with standard ECMAScript Modules (ESM). The built-in HiGHS WebAssembly binary is loaded directly from the installed package.
|
|
397
|
+
|
|
398
|
+
```typescript
|
|
399
|
+
import { Model, expr, maximize } from "@ohtaman/washi";
|
|
400
|
+
|
|
401
|
+
const m = new Model("NodeExample");
|
|
402
|
+
const x = m.continuous("x", { lb: 0, ub: 10 });
|
|
403
|
+
const y = m.continuous("y", { lb: 0, ub: 20 });
|
|
404
|
+
|
|
405
|
+
m.objective(maximize(expr`2 * ${x} + 3 * ${y}`));
|
|
406
|
+
m.constraint(expr`${x} + ${y} <= 15`, "capacity");
|
|
407
|
+
|
|
408
|
+
await m.solve();
|
|
409
|
+
console.log(`Status: ${m.status}, Objective: ${m.objectiveValue}`);
|
|
410
|
+
console.log(`x = ${x.value}, y = ${y.value}`);
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
Run directly with `tsx` or `node`:
|
|
414
|
+
|
|
415
|
+
```bash
|
|
416
|
+
npx tsx app.ts
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
---
|
|
420
|
+
|
|
421
|
+
## 🌐 Bundlers (Vite, Webpack, Rollup, Next.js)
|
|
422
|
+
|
|
423
|
+
### Modern Bundlers (Vite / Rollup / Webpack)
|
|
424
|
+
|
|
425
|
+
When importing `@ohtaman/washi` in a browser bundler, the `browser` export condition resolves automatically:
|
|
426
|
+
|
|
427
|
+
```typescript
|
|
428
|
+
import { Model, expr, maximize } from "@ohtaman/washi";
|
|
429
|
+
|
|
430
|
+
export async function runOptimization() {
|
|
431
|
+
const m = new Model({ solver: "highs" });
|
|
432
|
+
const x = m.continuous("x", { lb: 0, ub: 100 });
|
|
433
|
+
m.objective(maximize(x));
|
|
434
|
+
await m.solve();
|
|
435
|
+
return x.value;
|
|
436
|
+
}
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
#### Background Web Worker Execution (Default)
|
|
440
|
+
In browsers, HiGHS automatically runs inside a dedicated **Web Worker** so long-running LP/MIP/QP solves never freeze the main UI thread, animations, or user input, while enabling real-time progress events (`onProgress`) and cancellation (`AbortSignal`).
|
|
441
|
+
|
|
442
|
+
If your environment prohibits Web Workers or you explicitly want synchronous main-thread execution, pass `useWorker: false`:
|
|
443
|
+
|
|
444
|
+
```typescript
|
|
445
|
+
await m.solve({ useWorker: false });
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
#### Customizing the HiGHS WASM Asset URL
|
|
449
|
+
By default, WaSHI resolves `highs.wasm` relative to the bundle (`new URL("./highs.wasm", import.meta.url)`). It **never** makes implicit requests to external CDNs (`jsdelivr`, `unpkg`, etc.).
|
|
450
|
+
|
|
451
|
+
If your bundler or static server hosts `.wasm` files under a custom path, configure the URL globally or per solve:
|
|
452
|
+
|
|
453
|
+
```typescript
|
|
454
|
+
import { setHighsWasmUrl } from "@ohtaman/washi";
|
|
455
|
+
|
|
456
|
+
// Option A: Configure globally before solving
|
|
457
|
+
setHighsWasmUrl("/assets/wasm/highs.wasm");
|
|
458
|
+
|
|
459
|
+
// Option B: Configure per solve call
|
|
460
|
+
await m.solve({ wasmUrl: "/assets/wasm/highs.wasm" });
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
### Next.js & Server-Side Rendering (SSR)
|
|
464
|
+
|
|
465
|
+
In Next.js App Router or Pages Router, optimization models can run either in Server Actions / API Routes (Node.js runtime) or on the client inside `"use client"` components:
|
|
466
|
+
|
|
467
|
+
```tsx
|
|
468
|
+
"use client";
|
|
469
|
+
|
|
470
|
+
import { useState } from "react";
|
|
471
|
+
import { Model, maximize } from "@ohtaman/washi";
|
|
472
|
+
|
|
473
|
+
export default function OptimizerComponent() {
|
|
474
|
+
const [val, setVal] = useState<number | null>(null);
|
|
475
|
+
|
|
476
|
+
const handleSolve = async () => {
|
|
477
|
+
const m = new Model();
|
|
478
|
+
const x = m.continuous("x", { lb: 0, ub: 50 });
|
|
479
|
+
m.objective(maximize(x));
|
|
480
|
+
await m.solve();
|
|
481
|
+
setVal(x.value);
|
|
482
|
+
};
|
|
483
|
+
|
|
484
|
+
return (
|
|
485
|
+
<div>
|
|
486
|
+
<button onClick={handleSolve}>Run Optimization</button>
|
|
487
|
+
{val !== null && <p>Result: {val}</p>}
|
|
488
|
+
</div>
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
```
|
|
492
|
+
|
|
493
|
+
---
|
|
494
|
+
|
|
495
|
+
## 📦 Browser ESM (`<script type="module">`)
|
|
496
|
+
|
|
497
|
+
You can import the pre-bundled browser ESM modules directly from a local static server (or from an npm CDN once a compatible release is published) without a build step:
|
|
498
|
+
|
|
499
|
+
```html
|
|
500
|
+
<script type="module">
|
|
501
|
+
// Serve from your local static server / dist:
|
|
502
|
+
import { Model, expr, maximize } from "./dist/browser/washi.js";
|
|
503
|
+
// Or from an npm CDN once a compatible release is published:
|
|
504
|
+
// import { Model, expr, maximize } from "https://cdn.jsdelivr.net/npm/@ohtaman/washi@<version>/dist/browser/washi.js";
|
|
505
|
+
// Optional solver plugins:
|
|
506
|
+
// import "./dist/browser/solvers/glpk.js";
|
|
507
|
+
// import "./dist/browser/solvers/z3.js";
|
|
508
|
+
|
|
509
|
+
const m = new Model("BrowserESM");
|
|
510
|
+
const x = m.integer("x", { lb: 0 });
|
|
511
|
+
const y = m.integer("y", { lb: 0 });
|
|
512
|
+
|
|
513
|
+
m.objective(maximize(expr`30 * ${x} + 50 * ${y}`));
|
|
514
|
+
m.constraint(expr`${x} + 2 * ${y} <= 40`, "wood");
|
|
515
|
+
m.constraint(expr`2 * ${x} + ${y} <= 50`, "labor");
|
|
516
|
+
|
|
517
|
+
await m.solve();
|
|
518
|
+
console.log("Optimal Objective:", m.objectiveValue);
|
|
519
|
+
</script>
|
|
520
|
+
```
|
|
521
|
+
|
|
522
|
+
---
|
|
523
|
+
|
|
524
|
+
## 🟨 Classic `<script>` Tags (IIFE / Standalone Build / CDN)
|
|
525
|
+
|
|
526
|
+
For static HTML pages, CodePen, or JSFiddle, load `washi.min.js` via a standard `<script>` tag. All exports are attached to the global `window.Washi` object:
|
|
527
|
+
|
|
528
|
+
```html
|
|
529
|
+
<!DOCTYPE html>
|
|
530
|
+
<html>
|
|
531
|
+
<head>
|
|
532
|
+
<meta charset="utf-8" />
|
|
533
|
+
<title>WaSHI Browser Demo</title>
|
|
534
|
+
</head>
|
|
535
|
+
<body>
|
|
536
|
+
<h1>WaSHI Browser Optimization</h1>
|
|
537
|
+
<pre id="output">Solving...</pre>
|
|
538
|
+
|
|
539
|
+
<!-- Core WaSHI + HiGHS (MIT) from local static build / assets -->
|
|
540
|
+
<script src="./dist/browser/washi.min.js"></script>
|
|
541
|
+
<!-- Or via CDN once a compatible release is published: -->
|
|
542
|
+
<!-- <script src="https://cdn.jsdelivr.net/npm/@ohtaman/washi@<version>/dist/browser/washi.min.js"></script> -->
|
|
543
|
+
<!-- Optional plugins (auto-register on window.Washi): -->
|
|
544
|
+
<!-- <script src="./dist/browser/solvers/glpk.min.js"></script> -->
|
|
545
|
+
<!-- <script src="./dist/browser/solvers/z3.min.js"></script> -->
|
|
546
|
+
|
|
547
|
+
<script>
|
|
548
|
+
(async () => {
|
|
549
|
+
const { Model, expr, maximize } = Washi;
|
|
550
|
+
|
|
551
|
+
const m = new Model("BrowserDemo");
|
|
552
|
+
const x = m.continuous("x", { lb: 0, ub: 10 });
|
|
553
|
+
const y = m.continuous("y", { lb: 0, ub: 20 });
|
|
554
|
+
|
|
555
|
+
m.objective(maximize(expr`2 * ${x} + 3 * ${y}`));
|
|
556
|
+
m.constraint(expr`${x} + ${y} <= 15`);
|
|
557
|
+
|
|
558
|
+
await m.solve();
|
|
559
|
+
|
|
560
|
+
document.getElementById("output").textContent =
|
|
561
|
+
`Status: ${m.status}\n` +
|
|
562
|
+
`Objective: ${m.objectiveValue}\n` +
|
|
563
|
+
`x = ${x.value}, y = ${y.value}`;
|
|
564
|
+
})();
|
|
565
|
+
</script>
|
|
566
|
+
</body>
|
|
567
|
+
</html>
|
|
568
|
+
```
|
|
569
|
+
|
|
570
|
+
> [!NOTE]
|
|
571
|
+
> When loading `washi.min.js` from a cross-origin CDN (such as jsDelivr or unpkg), browsers block direct `new Worker("https://cdn...")` construction due to the same-origin policy. WaSHI automatically bridges cross-origin workers via a lightweight `Blob` wrapper using `importScripts()`, so Web Worker execution works out of the box from CDNs.
|
|
572
|
+
|
|
573
|
+
---
|
|
574
|
+
|
|
575
|
+
## 🔒 Offline, Intranet & Strict CSP Environments
|
|
576
|
+
|
|
577
|
+
WaSHI never phones home or falls back to public CDNs. To self-host in an air-gapped intranet or under a strict Content Security Policy (CSP):
|
|
578
|
+
|
|
579
|
+
1. **Copy Static Assets**: Serve the files from `node_modules/@ohtaman/washi/dist/browser/` on your own origin:
|
|
580
|
+
- `washi.js` / `washi.min.js`, `highs.wasm`, and `highs-worker.js`
|
|
581
|
+
- Optional GLPK: `solvers/glpk.js` / `solvers/glpk.min.js`
|
|
582
|
+
- Optional Z3: `solvers/z3.js` / `solvers/z3.min.js`, `solvers/z3-built.js`, `solvers/z3-built.wasm`
|
|
583
|
+
2. **Content Security Policy (CSP) Headers**:
|
|
584
|
+
- Allow WebAssembly compilation (`'wasm-unsafe-eval'` in `script-src`).
|
|
585
|
+
- If using the default Web Worker or cross-origin Blob worker fallback, allow `worker-src 'self' blob:;`.
|
|
586
|
+
- If `blob:` workers are prohibited by policy, either serve `highs-worker.js` from `'self'` or run on the main thread with `await m.solve({ useWorker: false })`.
|
|
587
|
+
3. **Cross-Origin Isolation (Z3 Only)**:
|
|
588
|
+
- HiGHS and GLPK run in standard browser contexts without special headers.
|
|
589
|
+
- Z3 WASM uses pthreads + `SharedArrayBuffer` and requires `Cross-Origin-Opener-Policy: same-origin` and `Cross-Origin-Embedder-Policy: require-corp` (or `credentialless`). See the [Cross-Origin Isolation Guide](docs/getting-started/cross-origin.md) for setup details.
|
|
590
|
+
|
|
591
|
+
---
|
|
592
|
+
|
|
593
|
+
## ⚡ Bun & Deno
|
|
594
|
+
|
|
595
|
+
### Bun
|
|
596
|
+
|
|
597
|
+
```bash
|
|
598
|
+
bun add @ohtaman/washi
|
|
599
|
+
bun run app.ts
|
|
600
|
+
```
|
|
601
|
+
|
|
602
|
+
### Deno
|
|
603
|
+
|
|
604
|
+
In Deno (v1.30+ with `npm:` specifiers):
|
|
605
|
+
|
|
606
|
+
```typescript
|
|
607
|
+
import { Model, maximize } from "npm:@ohtaman/washi";
|
|
608
|
+
|
|
609
|
+
const m = new Model();
|
|
610
|
+
const x = m.continuous("x", { lb: 0, ub: 10 });
|
|
611
|
+
m.objective(maximize(x));
|
|
612
|
+
await m.solve();
|
|
613
|
+
console.log("x =", x.value);
|
|
614
|
+
```
|
|
615
|
+
|
|
616
|
+
---
|
|
617
|
+
|
|
618
|
+
# Cross-Origin Isolation (Z3 & Browsers)
|
|
619
|
+
|
|
620
|
+
When running the **Z3** solver engine inside a web browser, special HTTP headers are required due to browser security models.
|
|
621
|
+
|
|
622
|
+
---
|
|
623
|
+
|
|
624
|
+
## Why is Cross-Origin Isolation Required?
|
|
625
|
+
|
|
626
|
+
The Z3 WebAssembly engine utilizes multithreading backed by `SharedArrayBuffer` and Web Workers.
|
|
627
|
+
|
|
628
|
+
In modern browsers (Chrome, Firefox, Safari, Edge), `SharedArrayBuffer` is disabled by default to mitigate Spectre-style timing side-channel attacks. To re-enable it, the hosting page must be **Cross-Origin Isolated**.
|
|
629
|
+
|
|
630
|
+
---
|
|
631
|
+
|
|
632
|
+
## Required HTTP Response Headers
|
|
633
|
+
|
|
634
|
+
To establish cross-origin isolation, your web server must serve the top-level HTML document with the following HTTP headers:
|
|
635
|
+
|
|
636
|
+
```http
|
|
637
|
+
Cross-Origin-Opener-Policy: same-origin
|
|
638
|
+
Cross-Origin-Embedder-Policy: require-corp
|
|
639
|
+
```
|
|
640
|
+
|
|
641
|
+
In addition, any cross-origin assets (e.g. scripts or WASM files loaded from external domains) must send:
|
|
642
|
+
|
|
643
|
+
```http
|
|
644
|
+
Cross-Origin-Resource-Policy: cross-origin
|
|
645
|
+
```
|
|
646
|
+
|
|
647
|
+
---
|
|
648
|
+
|
|
649
|
+
## Solutions by Hosting Environment
|
|
650
|
+
|
|
651
|
+
### 1. Custom Node.js / Express Server
|
|
652
|
+
|
|
653
|
+
```javascript
|
|
654
|
+
app.use((req, res, next) => {
|
|
655
|
+
res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
|
|
656
|
+
res.setHeader("Cross-Origin-Embedder-Policy", "require-corp");
|
|
657
|
+
next();
|
|
658
|
+
});
|
|
659
|
+
```
|
|
660
|
+
|
|
661
|
+
### 2. Next.js (`next.config.js`)
|
|
662
|
+
|
|
663
|
+
```javascript
|
|
664
|
+
/** @type {import('next').NextConfig} */
|
|
665
|
+
const nextConfig = {
|
|
666
|
+
async headers() {
|
|
667
|
+
return [
|
|
668
|
+
{
|
|
669
|
+
source: "/(.*)",
|
|
670
|
+
headers: [
|
|
671
|
+
{ key: "Cross-Origin-Opener-Policy", value: "same-origin" },
|
|
672
|
+
{ key: "Cross-Origin-Embedder-Policy", value: "require-corp" },
|
|
673
|
+
],
|
|
674
|
+
},
|
|
675
|
+
];
|
|
676
|
+
},
|
|
677
|
+
};
|
|
678
|
+
|
|
679
|
+
module.exports = nextConfig;
|
|
680
|
+
```
|
|
681
|
+
|
|
682
|
+
### 3. Static Hosting without Header Control (e.g., GitHub Pages)
|
|
683
|
+
|
|
684
|
+
Platforms like GitHub Pages or preview environments do not allow customizing response headers directly.
|
|
685
|
+
|
|
686
|
+
For these environments, you can use **`coi-serviceworker`** (a lightweight Service Worker that injects isolation headers on the client side):
|
|
687
|
+
|
|
688
|
+
1. Place `coi-serviceworker.js` in your website root.
|
|
689
|
+
2. In your HTML `<head>`, include:
|
|
690
|
+
|
|
691
|
+
```html
|
|
692
|
+
<script src="/coi-serviceworker.js"></script>
|
|
693
|
+
```
|
|
694
|
+
|
|
695
|
+
When a user visits for the first time, the service worker registers and reloads the page once to establish `window.crossOriginIsolated === true`.
|
|
696
|
+
|
|
697
|
+
---
|
|
698
|
+
|
|
699
|
+
## Graceful Degradation in WaSHI
|
|
700
|
+
|
|
701
|
+
WaSHI's Z3 solver adapter automatically inspects the environment at runtime. If Z3 is invoked in a browser lacking cross-origin isolation, it immediately rejects with a clear, informative error message explaining that COOP/COEP headers or a service worker are required, rather than hanging silently.
|
|
702
|
+
|
|
703
|
+
> [!TIP]
|
|
704
|
+
> The default **HiGHS** solver engine does not require cross-origin isolation or `SharedArrayBuffer`. It works out of the box in standard browser contexts.
|
|
705
|
+
|
|
706
|
+
---
|
|
707
|
+
|
|
708
|
+
# Variables & Domains
|
|
709
|
+
|
|
710
|
+
Variables represent the mathematical decision variables in your optimization model.
|
|
711
|
+
WaSHI provides domain-specific factory methods on the `Model` instance: `m.continuous()`, `m.integer()`, `m.binary()`, and `m.bool()`.
|
|
712
|
+
|
|
713
|
+
---
|
|
714
|
+
|
|
715
|
+
## Decision Variable Types
|
|
716
|
+
|
|
717
|
+
### 1. Continuous Variables (`m.continuous`)
|
|
718
|
+
Can take any real number value within `[lb, ub]`. Ideal for physical measurements, flow rates, budgets, and weights:
|
|
719
|
+
|
|
720
|
+
```typescript
|
|
721
|
+
import { Model } from "@ohtaman/washi";
|
|
722
|
+
|
|
723
|
+
const m = new Model();
|
|
724
|
+
|
|
725
|
+
// Single continuous variable bounded in [0, 1000]
|
|
726
|
+
const fuel = m.continuous("fuel", { lb: 0, ub: 1000 });
|
|
727
|
+
```
|
|
728
|
+
|
|
729
|
+
### 2. Integer Variables (`m.integer`)
|
|
730
|
+
Restricted to integers. Ideal for countable assets (machines, trucks, staff count):
|
|
731
|
+
|
|
732
|
+
```typescript
|
|
733
|
+
// Single integer variable bounded in [0, 15]
|
|
734
|
+
const trucks = m.integer("trucks", { lb: 0, ub: 15 });
|
|
735
|
+
```
|
|
736
|
+
|
|
737
|
+
### 3. Binary Variables (`m.binary`)
|
|
738
|
+
Restricted to $\{0, 1\}$ as mathematical 0-1 values. Ideal for MIP selection, yes/no decisions, and facility selections:
|
|
739
|
+
|
|
740
|
+
```typescript
|
|
741
|
+
// Single binary variable
|
|
742
|
+
const openWarehouse = m.binary("open_warehouse");
|
|
743
|
+
// lb is automatically 0, ub is automatically 1
|
|
744
|
+
```
|
|
745
|
+
|
|
746
|
+
### 4. Propositional Boolean Variables (`m.bool`)
|
|
747
|
+
Pure boolean logic propositions for SMT / SAT modeling with Z3:
|
|
748
|
+
|
|
749
|
+
```typescript
|
|
750
|
+
// Single propositional boolean variable
|
|
751
|
+
const isNight = m.bool("isNight");
|
|
752
|
+
const alarmTriggered = m.bool("alarmTriggered");
|
|
753
|
+
|
|
754
|
+
// First-class boolean operators: and, or, not, implies, xor
|
|
755
|
+
m.constraint(alarmTriggered.implies(isNight), "alarm_policy");
|
|
756
|
+
```
|
|
757
|
+
|
|
758
|
+
### 💡 Modeling Semi-Continuous Decisions
|
|
759
|
+
Semi-continuous variables take values in $\{0\} \cup [L, U]$ (either zero or between a positive lower and upper bound, useful for minimum production batches or operational thresholds).
|
|
760
|
+
|
|
761
|
+
While not a primitive `VarType` in `Model`, semi-continuous behavior is cleanly modeled by combining a continuous variable with a binary indicator variable and linking constraints ($L \cdot b \le x \le U \cdot b$):
|
|
762
|
+
|
|
763
|
+
```typescript
|
|
764
|
+
import { Model, expr } from "@ohtaman/washi";
|
|
765
|
+
|
|
766
|
+
const m = new Model();
|
|
767
|
+
const batch = m.continuous("batch", { lb: 0, ub: 100 });
|
|
768
|
+
const isActive = m.binary("is_active");
|
|
769
|
+
|
|
770
|
+
// If isActive === 0, batch is forced to 0; if 1, batch is within [10, 100]
|
|
771
|
+
m.constraint(expr`${batch} >= 10 * ${isActive}`, "min_batch_run");
|
|
772
|
+
m.constraint(expr`${batch} <= 100 * ${isActive}`, "max_batch_run");
|
|
773
|
+
```
|
|
774
|
+
|
|
775
|
+
---
|
|
776
|
+
|
|
777
|
+
## Multi-Dimensional & Array Creation
|
|
778
|
+
|
|
779
|
+
The domain factory methods (`continuous`, `integer`, `binary`, `bool`) support count, arrays of keys, and row/column keys to create 1D arrays, typed dictionaries, and 2D matrices in a single call.
|
|
780
|
+
|
|
781
|
+
### By Count
|
|
782
|
+
Pass a number to create an array of variables:
|
|
783
|
+
|
|
784
|
+
```typescript
|
|
785
|
+
const x = m.continuous(5, { lb: 0 }); // Var[] (x_0, ..., x_4)
|
|
786
|
+
const flags = m.bool(3); // BoolVar[] (p_0, p_1, p_2)
|
|
787
|
+
```
|
|
788
|
+
|
|
789
|
+
### 1D Variable Dictionaries
|
|
790
|
+
Pass an array of keys to get a typed dictionary `Record<string, Var>` or `Record<string, BoolVar>`:
|
|
791
|
+
|
|
792
|
+
```typescript
|
|
793
|
+
const items = ["laptop", "phone", "tablet"];
|
|
794
|
+
|
|
795
|
+
// Record<"laptop" | "phone" | "tablet", Var>
|
|
796
|
+
const selected = m.binary(items);
|
|
797
|
+
|
|
798
|
+
const perms = m.bool(["read", "write", "execute"]);
|
|
799
|
+
// perms.read, perms.write, perms.execute
|
|
800
|
+
```
|
|
801
|
+
|
|
802
|
+
### 2D Variable Matrices & Grids
|
|
803
|
+
Each dimension follows a consistent rule: passing a numeric count (`number`) produces an `Array`, and passing a collection of keys produces a `Record`:
|
|
804
|
+
- `(rowsCount, colsCount)` $\rightarrow$ `Var[][]` (or `BoolVar[][]`)
|
|
805
|
+
- `(rowKeys, colKeys)` $\rightarrow$ `Record<R, Record<C, Var>>` (or `Record<R, Record<C, BoolVar>>`)
|
|
806
|
+
- `(rowsCount, colKeys)` $\rightarrow$ `Array<Record<C, Var>>`
|
|
807
|
+
- `(rowKeys, colsCount)` $\rightarrow$ `Record<R, Var[]>`
|
|
808
|
+
|
|
809
|
+
```typescript
|
|
810
|
+
const factories = ["Tokyo", "Osaka"];
|
|
811
|
+
const stores = ["StoreA", "StoreB", "StoreC"];
|
|
812
|
+
|
|
813
|
+
// 1. Named keys x Named keys -> Record<string, Record<string, Var>>
|
|
814
|
+
const ship = m.continuous(factories, stores, { lb: 0 });
|
|
815
|
+
// ship.Tokyo.StoreA
|
|
816
|
+
|
|
817
|
+
// 2. Numeric count x Numeric count -> Var[][] (2D Array)
|
|
818
|
+
const board = m.binary(8, 8, { prefix: "queen" });
|
|
819
|
+
// board[0][0], board.map(...)
|
|
820
|
+
|
|
821
|
+
// 3. Mixed dimensions: Named keys x Numeric count -> Record<string, Var[]>
|
|
822
|
+
const schedule = m.binary(factories, 7, { prefix: "open_day" });
|
|
823
|
+
// schedule.Tokyo[0]
|
|
824
|
+
```
|
|
825
|
+
|
|
826
|
+
---
|
|
827
|
+
|
|
828
|
+
## Variable Options
|
|
829
|
+
|
|
830
|
+
| Option | Type | Default | Description |
|
|
831
|
+
| :--- | :--- | :--- | :--- |
|
|
832
|
+
| `lb` | `number` | `0` | Lower bound. Defaults to 0 (non-negative). |
|
|
833
|
+
| `ub` | `number` | `Infinity` (or `1` for binary) | Upper bound. |
|
|
834
|
+
| `type` | `"Continuous"` \| `"Integer"` \| `"Binary"` | Inferred from method | Underlying variable domain category. |
|
|
835
|
+
| `initialValue` | `number` | `undefined` | Optional primal solution hint (MIP start), kept distinct from solved `variable.value`. |
|
|
836
|
+
|
|
837
|
+
---
|
|
838
|
+
|
|
839
|
+
## Primal Initial Values (Warm-Start Hints)
|
|
840
|
+
|
|
841
|
+
When rebuilding a model across iterations with stable variable names, you can supply optional primal initial values (`initialValue`) on a subset or all numeric variables (`m.continuous`, `m.integer`, `m.binary`, or `v.initialValue = ...`):
|
|
842
|
+
|
|
843
|
+
```typescript
|
|
844
|
+
const prevValues: Record<string, number | null> = { trucks: 8, open_warehouse: 1 };
|
|
845
|
+
|
|
846
|
+
const m2 = new Model("RebuiltMIP");
|
|
847
|
+
const trucks2 = m2.integer("trucks", {
|
|
848
|
+
lb: 0,
|
|
849
|
+
ub: 15,
|
|
850
|
+
initialValue: prevValues.trucks ?? undefined,
|
|
851
|
+
});
|
|
852
|
+
const openWarehouse2 = m2.binary("open_warehouse", {
|
|
853
|
+
initialValue: prevValues.open_warehouse ?? undefined,
|
|
854
|
+
});
|
|
855
|
+
const extraCapacity = m2.continuous("extra_capacity", { lb: 0 }); // Partial start: no initialValue needed
|
|
856
|
+
```
|
|
857
|
+
|
|
858
|
+
- **Hint vs. Hard Constraint**: `initialValue` is strictly an optional solver hint (passed to HiGHS via `setSolution`). It does **not** fix the variable, add a constraint, or guarantee either solver acceptance or a runtime speedup. It remains separate from `variable.value` (which is `null` until solved and populated with the solver's primal solution).
|
|
859
|
+
- **Validation & Tolerance (`1e-6`)**: Supplied values must be finite numbers within `[lb - 1e-6, ub + 1e-6]` (and within `1e-6` of an integer inside bounds for `"Integer"` and `"Binary"` variables). Partial starts are not required to satisfy every model constraint.
|
|
860
|
+
- **Solver Support**: Supported by **HiGHS** (`capabilities.supportsWarmStart === true`). Solvers without warm-start support (**GLPK**, **Z3**, or custom adapters without `supportsWarmStart: true`) reject `solve()` and `check()` with a descriptive error rather than silently ignoring `initialValue`.
|
|
861
|
+
|
|
862
|
+
---
|
|
863
|
+
|
|
864
|
+
## Accessing Solution Values & Reduced Costs
|
|
865
|
+
|
|
866
|
+
After calling `await m.solve()` or `await m.check()`, assigned variable values (optimal or incumbent primal values from `solve()`, or a satisfying assignment from `check()`) are accessible via `variable.value` (returns `number | null` for `Var`, or `boolean | null` for `BoolVar`) or the bulk accessor `m.getValues()`:
|
|
867
|
+
|
|
868
|
+
```typescript
|
|
869
|
+
await m.solve();
|
|
870
|
+
|
|
871
|
+
// Single variable primal value
|
|
872
|
+
console.log(`trucks: ${trucks.value}`);
|
|
873
|
+
|
|
874
|
+
// Reduced cost (LP sensitivity analysis — how much an objective coefficient must improve before a variable at its bound enters the basis)
|
|
875
|
+
console.log(`fuel reduced cost: ${fuel.reducedCost}`);
|
|
876
|
+
|
|
877
|
+
// Bulk extraction for 1D or 2D collections (works for both Var and BoolVar collections)
|
|
878
|
+
const selectedValues = m.getValues(selected);
|
|
879
|
+
// { laptop: 1, phone: 0, tablet: 1 }
|
|
880
|
+
|
|
881
|
+
const flowValues = m.getValues(ship);
|
|
882
|
+
// { Tokyo: { StoreA: 50, ... }, Osaka: { ... } }
|
|
883
|
+
```
|
|
884
|
+
|
|
885
|
+
---
|
|
886
|
+
|
|
887
|
+
## Looking Up Variables by Name
|
|
888
|
+
|
|
889
|
+
You can retrieve registered decision variables by name or inspect the total variable count on the model:
|
|
890
|
+
|
|
891
|
+
```typescript
|
|
892
|
+
const v = m.getVarByName("trucks"); // Var | undefined
|
|
893
|
+
const b = m.getBoolVarByName("isNight"); // BoolVar | undefined
|
|
894
|
+
console.log("Total numeric variables:", m.numVars);
|
|
895
|
+
```
|
|
896
|
+
|
|
897
|
+
---
|
|
898
|
+
|
|
899
|
+
# Expressions (`expr`, `sum`, `dot`)
|
|
900
|
+
|
|
901
|
+
Mathematical expressions are linear or quadratic combinations of decision variables and numeric coefficients.
|
|
902
|
+
|
|
903
|
+
In JavaScript and TypeScript—which do not support operator overloading—WaSHI provides the **unified tagged template literal (`expr`)**, **`sum()`**, and matrix/vector **`dot()`** to construct algebraic expressions without losing static type safety.
|
|
904
|
+
|
|
905
|
+
| Approach | Example | Infix readability | Type safety & IDE rename | Indexed variables (`flow[i][j]`) |
|
|
906
|
+
| :--- | :--- | :--- | :--- | :--- |
|
|
907
|
+
| Method chaining | `x.mul(30).add(y.mul(50)).le(40)` | Low | Yes | Yes |
|
|
908
|
+
| Raw string parsing | `parseConstraint("30 * x + 50 * y <= 40", resolver)` | High | No (runtime string lookup) | Fragile (string concatenation) |
|
|
909
|
+
| **Tagged template (`expr`)** | `` expr`30 * ${x} + 50 * ${y} <= 40` `` | **High** | **Yes (actual TS references)** | **Yes (bound by object reference)** |
|
|
910
|
+
|
|
911
|
+
---
|
|
912
|
+
|
|
913
|
+
## ✍️ Tagged Template Literal (`expr`)
|
|
914
|
+
|
|
915
|
+
Similar to parameterized `sql` tagged templates, `expr` receives the static operator text (`+`, `-`, `*`, `^2`, `<=`, `>=`, `==`) separately from the interpolated `${...}` values (`Var`, `LinExpr`, `QuadExpr`, or finite `number`). Variables are bound by object reference rather than resolved by string name, avoiding naming collisions and preserving TypeScript type checking and IDE refactoring.
|
|
916
|
+
|
|
917
|
+
Use `expr` for both arithmetic expressions (`LinExpr | QuadExpr`) and relational constraints (`Constraint`):
|
|
918
|
+
|
|
919
|
+
```typescript
|
|
920
|
+
import { Model, expr } from "@ohtaman/washi";
|
|
921
|
+
|
|
922
|
+
const m = new Model();
|
|
923
|
+
const x = m.continuous("x", { lb: 0 });
|
|
924
|
+
const y = m.continuous("y", { lb: 0 });
|
|
925
|
+
|
|
926
|
+
// 1. Linear combination: 3*x + 5*y - 10
|
|
927
|
+
const linearExpr = expr`3 * ${x} + 5 * ${y} - 10`;
|
|
928
|
+
|
|
929
|
+
// 2. Quadratic expression: x * y + 2 * x^2
|
|
930
|
+
const quadExpr = expr`${x} * ${y} + 2 * ${x} * ${x}`;
|
|
931
|
+
|
|
932
|
+
// 3. Relational constraint:
|
|
933
|
+
const balanceConstraint = expr`${x} + 2 * ${y} <= 40`;
|
|
934
|
+
```
|
|
935
|
+
|
|
936
|
+
### Supported Syntax in `expr`
|
|
937
|
+
- **Addition & Subtraction**: `${x} + ${y} - 5`
|
|
938
|
+
- **Scalar Multiplication**: `4 * ${x}`, `${x} * 4`
|
|
939
|
+
- **Variable Multiplication (Quadratic terms)**: `${x} * ${y}`, `2 * ${x} * ${x}`
|
|
940
|
+
- **Powers & Exponentiation**: `${x}^2`, `${x}**2`
|
|
941
|
+
- **Unary Negation**: `-${x} + 10`
|
|
942
|
+
- **Parentheses**: `2 * (${x} + ${y})`
|
|
943
|
+
- **Nested Expressions**: `expr` seamlessly interpolates variables, numbers, `sum()` results, and nested expressions.
|
|
944
|
+
|
|
945
|
+
> [!NOTE]
|
|
946
|
+
> For relational comparisons (`<=`, `>=`, `==`), `expr` (e.g., ``expr`${x} + 2 * ${y} <= 40` ``) returns a first-class `Constraint`.
|
|
947
|
+
> For arithmetic expressions without comparison operators, `expr` uniformly handles linear and quadratic expressions (up to degree 2): if quadratic terms are present, `expr` returns a `QuadExpr`; otherwise, it returns a `LinExpr`. Polynomial terms of degree $> 2$ (e.g. ``${x} * ${y} * ${z}``, ``${x}^3``) and non-linear constraints (e.g. ``${x} * ${y} <= 10``) are rejected because standard MILP/LP/QP formulations only support linear constraints and quadratic objectives.
|
|
948
|
+
|
|
949
|
+
---
|
|
950
|
+
|
|
951
|
+
## ⚡ High-Performance Summation (`sum`)
|
|
952
|
+
|
|
953
|
+
For iterating over large collections of variables or indices:
|
|
954
|
+
|
|
955
|
+
```typescript
|
|
956
|
+
import { sum } from "@ohtaman/washi";
|
|
957
|
+
|
|
958
|
+
const items = ["A", "B", "C"];
|
|
959
|
+
const weights = { A: 10, B: 20, C: 30 };
|
|
960
|
+
const x = m.binary(items);
|
|
961
|
+
|
|
962
|
+
// 1. Transform elements with a mapping callback:
|
|
963
|
+
const totalWeight = sum(items, (item) => x[item].mul(weights[item]));
|
|
964
|
+
|
|
965
|
+
// 2. Direct array summation:
|
|
966
|
+
const count = sum(Object.values(x));
|
|
967
|
+
```
|
|
968
|
+
|
|
969
|
+
`sum()` runs in $O(N)$ linear time without AST overhead.
|
|
970
|
+
|
|
971
|
+
---
|
|
972
|
+
|
|
973
|
+
## 🎯 Dot Product (`dot`)
|
|
974
|
+
|
|
975
|
+
The `dot(a, b)` helper computes linear combinations of numeric coefficients (`number`) and variables or linear expressions (`Var | LinExpr`) in either operand order (`dot(coeffs, vars)` or `dot(vars, coeffs)`).
|
|
976
|
+
|
|
977
|
+
Supported 1D and 2D container shapes:
|
|
978
|
+
- **1D Arrays**: `number[]` $\times$ `(Var | LinExpr)[]`
|
|
979
|
+
- **1D Records**: `Record<K, number>` $\times$ `Record<K, Var | LinExpr>`
|
|
980
|
+
- **2D Arrays**: `number[][]` $\times$ `(Var | LinExpr)[][]`
|
|
981
|
+
- **2D Records**: `Record<R, Record<C, number>>` $\times$ `Record<R, Record<C, Var | LinExpr>>`
|
|
982
|
+
- **2D Mixed**: `Array<Record<C, ...>>` or `Record<R, Array<...>>`
|
|
983
|
+
|
|
984
|
+
Mismatched array lengths, missing dictionary keys, or non-finite/non-linear leaves throw an explicit error indicating the offending path (e.g., `at '[0].StoreB'`).
|
|
985
|
+
|
|
986
|
+
### 1D Vector Dot Product
|
|
987
|
+
```typescript
|
|
988
|
+
import { dot } from "@ohtaman/washi";
|
|
989
|
+
|
|
990
|
+
const profits = { desk: 30, chair: 50 };
|
|
991
|
+
const vars = m.integer(["desk", "chair"]);
|
|
992
|
+
|
|
993
|
+
// Computes 30 * vars.desk + 50 * vars.chair
|
|
994
|
+
const totalProfit = dot(profits, vars);
|
|
995
|
+
```
|
|
996
|
+
|
|
997
|
+
### 2D Matrix Dot Product
|
|
998
|
+
Works directly with 2D arrays (`m.continuous(rows, cols)`), nested dictionaries (`m.continuous(rowKeys, colKeys)`), or mixed 2D containers:
|
|
999
|
+
|
|
1000
|
+
```typescript
|
|
1001
|
+
const factories = ["Tokyo", "Osaka"];
|
|
1002
|
+
const stores = ["StoreA", "StoreB"];
|
|
1003
|
+
|
|
1004
|
+
const costs = {
|
|
1005
|
+
Tokyo: { StoreA: 4, StoreB: 7 },
|
|
1006
|
+
Osaka: { StoreA: 8, StoreB: 3 },
|
|
1007
|
+
};
|
|
1008
|
+
|
|
1009
|
+
const ship = m.continuous(factories, stores);
|
|
1010
|
+
|
|
1011
|
+
// Total shipping cost in one line:
|
|
1012
|
+
m.objective(minimize(dot(costs, ship)));
|
|
1013
|
+
```
|
|
1014
|
+
|
|
1015
|
+
---
|
|
1016
|
+
|
|
1017
|
+
## 🧮 Quadratic Expressions (`QuadExpr` & `expr`)
|
|
1018
|
+
|
|
1019
|
+
WaSHI natively supports Convex Quadratic Programming (QP) with the HiGHS solver. Quadratic objectives can be written uniformly using the `expr` template literal, or algebraically using `QuadExpr` and `.mul()`:
|
|
1020
|
+
|
|
1021
|
+
### 1. Using `expr` (Recommended)
|
|
1022
|
+
You can write quadratic formulas naturally with `*`, `^2`, or `**2`:
|
|
1023
|
+
|
|
1024
|
+
```typescript
|
|
1025
|
+
import { Model, expr, minimize } from "@ohtaman/washi";
|
|
1026
|
+
|
|
1027
|
+
const m = new Model();
|
|
1028
|
+
const x = m.continuous("x", { lb: 0 });
|
|
1029
|
+
const y = m.continuous("y", { lb: 0 });
|
|
1030
|
+
|
|
1031
|
+
// Quadratic objective using expr:
|
|
1032
|
+
m.objective(minimize(expr`3 * ${x} + ${x}^2 + ${y}^2`));
|
|
1033
|
+
|
|
1034
|
+
// Linear constraint using expr:
|
|
1035
|
+
m.constraint(expr`${x} + ${y} >= 4`);
|
|
1036
|
+
```
|
|
1037
|
+
|
|
1038
|
+
### 2. Method Chaining with `.mul()` or `QuadExpr`
|
|
1039
|
+
Alternatively, you can construct quadratic expressions programmatically:
|
|
1040
|
+
|
|
1041
|
+
```typescript
|
|
1042
|
+
import { Model, QuadExpr, minimize } from "@ohtaman/washi";
|
|
1043
|
+
|
|
1044
|
+
const m = new Model();
|
|
1045
|
+
const x = m.continuous("x", { lb: 0 });
|
|
1046
|
+
const y = m.continuous("y", { lb: 0 });
|
|
1047
|
+
|
|
1048
|
+
// 1. Construct quadratic terms with .mul()
|
|
1049
|
+
// Represents x * y + 2 * x^2
|
|
1050
|
+
const quad = x.mul(y).add(x.mul(x).mul(2));
|
|
1051
|
+
|
|
1052
|
+
// 2. Build portfolio risk objective: w^T * Sigma * w
|
|
1053
|
+
const assets = ["Tech", "Bonds"];
|
|
1054
|
+
const w = m.continuous(assets, { lb: 0, ub: 1 });
|
|
1055
|
+
|
|
1056
|
+
let risk = new QuadExpr();
|
|
1057
|
+
risk = risk.add(w.Tech.mul(w.Tech).mul(0.04));
|
|
1058
|
+
risk = risk.add(w.Tech.mul(w.Bonds).mul(0.005));
|
|
1059
|
+
risk = risk.add(w.Bonds.mul(w.Bonds).mul(0.01));
|
|
1060
|
+
|
|
1061
|
+
m.objective(minimize(risk));
|
|
1062
|
+
```
|
|
1063
|
+
|
|
1064
|
+
---
|
|
1065
|
+
|
|
1066
|
+
## 📊 Evaluating Expressions After Solving (`.value`)
|
|
1067
|
+
|
|
1068
|
+
Both `LinExpr` and `QuadExpr` expose a `.value` getter (`number | null`). Once a model has been solved and all referenced variables have finite primal values, you can evaluate any sub-expression—such as total department cost, portfolio return, or resource utilization—directly without manual arithmetic:
|
|
1069
|
+
|
|
1070
|
+
```typescript
|
|
1071
|
+
const revenue = expr`30 * ${x} + 50 * ${y}`;
|
|
1072
|
+
const laborUsed = expr`2 * ${x} + ${y}`;
|
|
1073
|
+
|
|
1074
|
+
m.objective(maximize(revenue));
|
|
1075
|
+
m.constraint(expr`${x} + 2 * ${y} <= 40`, "wood_cap");
|
|
1076
|
+
m.constraint(expr`${laborUsed} <= 50`, "labor_cap");
|
|
1077
|
+
|
|
1078
|
+
await m.solve();
|
|
1079
|
+
|
|
1080
|
+
console.log("Total Revenue:", revenue.value); // 1100 (x = 20, y = 10)
|
|
1081
|
+
console.log("Labor Used:", laborUsed.value); // 50
|
|
1082
|
+
```
|
|
1083
|
+
|
|
1084
|
+
If the model has not been solved (or any variable in the expression has `value === null`), `.value` returns `null`.
|
|
1085
|
+
|
|
1086
|
+
---
|
|
1087
|
+
|
|
1088
|
+
## 🔗 Fluent Arithmetic & Relational Methods
|
|
1089
|
+
|
|
1090
|
+
In addition to `expr`, `Var`, `LinExpr`, and `QuadExpr` provide immutable fluent methods for programmatic expression building:
|
|
1091
|
+
|
|
1092
|
+
| Method | Supported On | Return Type | Description |
|
|
1093
|
+
| :--- | :--- | :--- | :--- |
|
|
1094
|
+
| `.add(other)` | `Var`, `LinExpr`, `QuadExpr` | `LinExpr` or `QuadExpr` | Adds a number, `Var`, `LinExpr`, or `QuadExpr`. |
|
|
1095
|
+
| `.sub(other)` | `Var`, `LinExpr`, `QuadExpr` | `LinExpr` or `QuadExpr` | Subtracts a number, `Var`, `LinExpr`, or `QuadExpr`. |
|
|
1096
|
+
| `.mul(other)` | `Var`, `LinExpr`, `QuadExpr` | `LinExpr` or `QuadExpr` | Multiplies by a scalar `number`, or multiplies two linear terms into a `QuadExpr`. |
|
|
1097
|
+
| `.div(divisor)` | `LinExpr`, `QuadExpr` | `LinExpr` or `QuadExpr` | Divides by a non-zero finite scalar `number`. |
|
|
1098
|
+
| `.neg()` | `Var`, `LinExpr`, `QuadExpr` | `LinExpr` or `QuadExpr` | Negates all terms ($-e$). |
|
|
1099
|
+
| `.le(rhs)` | `Var`, `LinExpr` | `Constraint` | Builds a $\le$ constraint (`lhs <= rhs`). |
|
|
1100
|
+
| `.ge(rhs)` | `Var`, `LinExpr` | `Constraint` | Builds a $\ge$ constraint (`lhs >= rhs`). |
|
|
1101
|
+
| `.eq(rhs)` | `Var`, `LinExpr` | `Constraint` | Builds an $==$ equality constraint (`lhs == rhs`). |
|
|
1102
|
+
|
|
1103
|
+
---
|
|
1104
|
+
|
|
1105
|
+
# Constraints (`m.constraint` & `m.indicator`)
|
|
1106
|
+
|
|
1107
|
+
Constraints restrict the feasible solution space of your mathematical or logical model.
|
|
1108
|
+
In WaSHI, both linear arithmetic constraints and propositional SMT formulas are registered using **`m.constraint()`**:
|
|
1109
|
+
|
|
1110
|
+
```typescript
|
|
1111
|
+
m.constraint(expr`${desks} + 2 * ${chairs} <= 40`, "wood_limit");
|
|
1112
|
+
```
|
|
1113
|
+
|
|
1114
|
+
---
|
|
1115
|
+
|
|
1116
|
+
## ✍️ Algebraic Linear Constraints (`expr`)
|
|
1117
|
+
|
|
1118
|
+
The `expr` tagged template literal syntax allows writing equations and inequalities with natural mathematical notation:
|
|
1119
|
+
|
|
1120
|
+
```typescript
|
|
1121
|
+
import { Model, expr } from "@ohtaman/washi";
|
|
1122
|
+
|
|
1123
|
+
const m = new Model();
|
|
1124
|
+
const x = m.continuous("x", { lb: 0 });
|
|
1125
|
+
const y = m.continuous("y", { lb: 0 });
|
|
1126
|
+
|
|
1127
|
+
// Upper bound inequality (<=)
|
|
1128
|
+
m.constraint(expr`${x} + 2 * ${y} <= 40`, "wood_limit");
|
|
1129
|
+
|
|
1130
|
+
// Lower bound inequality (>=)
|
|
1131
|
+
m.constraint(expr`3 * ${x} + ${y} >= 20`, "min_demand");
|
|
1132
|
+
|
|
1133
|
+
// Exact equality (==)
|
|
1134
|
+
m.constraint(expr`${x} + ${y} == 30`, "exact_quota");
|
|
1135
|
+
```
|
|
1136
|
+
|
|
1137
|
+
### Supported Comparison Operators
|
|
1138
|
+
- `<=`: Upper bound constraint
|
|
1139
|
+
- `>=`: Lower bound constraint
|
|
1140
|
+
- `==` or `=`: Exact equality constraint
|
|
1141
|
+
|
|
1142
|
+
### Batch Registration
|
|
1143
|
+
You can pass an array of constraints to `m.constraint([...])` to register them transactionally in a single call:
|
|
1144
|
+
|
|
1145
|
+
```typescript
|
|
1146
|
+
m.constraint([expr`${x} <= 10`, expr`${y} <= 20`]);
|
|
1147
|
+
```
|
|
1148
|
+
|
|
1149
|
+
---
|
|
1150
|
+
|
|
1151
|
+
## 🏷️ Naming Constraints
|
|
1152
|
+
|
|
1153
|
+
Every constraint can be given a unique, descriptive name as the second argument of `m.constraint(c, name)`:
|
|
1154
|
+
|
|
1155
|
+
```typescript
|
|
1156
|
+
m.constraint(expr`${x} + ${y} <= 100`, "budget_cap");
|
|
1157
|
+
```
|
|
1158
|
+
|
|
1159
|
+
### Why Name Constraints?
|
|
1160
|
+
1. **Model Inspection (`m.toLpString()`)**: Exported CPLEX LP strings retain these descriptive names (sanitized if needed) so you can inspect the exact generated formulation.
|
|
1161
|
+
2. **Conflict Diagnosis (`conflicts` / `unsatCore`)**: When constraints contradict each other, Z3 `m.check()` returns the exact constraint names forming the conflicting subset.
|
|
1162
|
+
3. **Direct Lookup (`m.getConstraintByName()`)**: Retrieve a constraint by name after solving to inspect its `dual` or `slack` value.
|
|
1163
|
+
|
|
1164
|
+
---
|
|
1165
|
+
|
|
1166
|
+
## 🔁 Adding Constraints over Iterables & Sums
|
|
1167
|
+
|
|
1168
|
+
Use `sum()` or `dot()` inside `expr` to express linear aggregations cleanly:
|
|
1169
|
+
|
|
1170
|
+
```typescript
|
|
1171
|
+
import { Model, sum, expr } from "@ohtaman/washi";
|
|
1172
|
+
|
|
1173
|
+
const items = ["A", "B", "C"];
|
|
1174
|
+
const weights = { A: 5, B: 10, C: 15 };
|
|
1175
|
+
const selected = m.binary(items);
|
|
1176
|
+
|
|
1177
|
+
// Total weight cannot exceed capacity 25
|
|
1178
|
+
m.constraint(
|
|
1179
|
+
expr`${sum(items, (i) => selected[i].mul(weights[i]))} <= 25`,
|
|
1180
|
+
"capacity_limit"
|
|
1181
|
+
);
|
|
1182
|
+
```
|
|
1183
|
+
|
|
1184
|
+
---
|
|
1185
|
+
|
|
1186
|
+
## 🎯 Conditional / Indicator Constraints (`m.indicator`)
|
|
1187
|
+
|
|
1188
|
+
When a linear constraint should only take effect when a binary decision variable is active (`1` or `0`), use `m.indicator()`:
|
|
1189
|
+
|
|
1190
|
+
```typescript
|
|
1191
|
+
const useDiscount = m.binary("use_discount");
|
|
1192
|
+
const cartTotal = m.continuous("cart_total", { lb: 0, ub: 10_000 });
|
|
1193
|
+
|
|
1194
|
+
// If useDiscount == 1, then cartTotal >= 100
|
|
1195
|
+
// WaSHI automatically derives a safe, tight Big-M from the variable bounds
|
|
1196
|
+
m.indicator(useDiscount, expr`${cartTotal} >= 100`, {
|
|
1197
|
+
activeValue: 1,
|
|
1198
|
+
name: "min_spend_for_discount",
|
|
1199
|
+
});
|
|
1200
|
+
```
|
|
1201
|
+
|
|
1202
|
+
### Safe Automatic Big-M Derivation & Bounds
|
|
1203
|
+
|
|
1204
|
+
When `options.M` is omitted, WaSHI automatically derives a safe, minimal Big-M value from the finite variable bounds:
|
|
1205
|
+
- For `<=` constraints, variables with positive coefficients require finite upper bounds (`ub`), and negative coefficients require finite lower bounds (`lb`).
|
|
1206
|
+
- For `>=` constraints, variables with positive coefficients require finite lower bounds (`lb`), and negative coefficients require finite upper bounds (`ub`).
|
|
1207
|
+
- For `==` constraints, safe bounds are derived separately for both the `<=` and `>=` directions (generating `name_le` and `name_ge`).
|
|
1208
|
+
|
|
1209
|
+
If any referenced variable lacks the required finite bounds, `m.indicator()` throws an actionable error prompting for finite bounds or an explicit `options.M`.
|
|
1210
|
+
|
|
1211
|
+
### Explicit Override & Numerical Stability
|
|
1212
|
+
|
|
1213
|
+
You can override the derived Big-M by providing `{ M: number }`:
|
|
1214
|
+
|
|
1215
|
+
```typescript
|
|
1216
|
+
m.indicator(useDiscount, expr`${cartTotal} >= 100`, {
|
|
1217
|
+
activeValue: 1,
|
|
1218
|
+
M: 10_000, // Explicit override
|
|
1219
|
+
});
|
|
1220
|
+
```
|
|
1221
|
+
|
|
1222
|
+
> [!TIP]
|
|
1223
|
+
> **Numerical Stability vs. Correctness**:
|
|
1224
|
+
> An $M$ value that is too small can inadvertently cut off valid solutions. Conversely, an excessively large $M$ (e.g., $10^9$) causes poor matrix conditioning and numerical instability in MIP solvers.
|
|
1225
|
+
> By setting explicit, realistic variable bounds (`lb` and `ub`), WaSHI derives the tightest possible safe Big-M automatically.
|
|
1226
|
+
|
|
1227
|
+
---
|
|
1228
|
+
|
|
1229
|
+
## 🧠 Propositional Logic & SMT Constraints (`BoolVar`, `and`, `or`, `not`, `implies`, `xor`)
|
|
1230
|
+
|
|
1231
|
+
When solving with the **Z3** SMT engine (`@ohtaman/washi/solvers/z3`), you can combine propositional boolean variables (`m.bool()`) and linear constraints (`Constraint`) using first-class logical operators:
|
|
1232
|
+
|
|
1233
|
+
```typescript
|
|
1234
|
+
import { Model, expr, and, or, not, implies, xor } from "@ohtaman/washi";
|
|
1235
|
+
import "@ohtaman/washi/solvers/z3";
|
|
1236
|
+
|
|
1237
|
+
const m = new Model({ solver: "z3" });
|
|
1238
|
+
const isAdmin = m.bool("isAdmin");
|
|
1239
|
+
const hasMfa = m.bool("hasMfa");
|
|
1240
|
+
const Clearance = m.integer("clearance", { lb: 0, ub: 5 });
|
|
1241
|
+
|
|
1242
|
+
// Method syntax on BoolVar / BoolOp:
|
|
1243
|
+
m.constraint(isAdmin.implies(hasMfa), "admin_requires_mfa");
|
|
1244
|
+
|
|
1245
|
+
// Mixing boolean propositions with linear arithmetic comparisons:
|
|
1246
|
+
m.constraint(
|
|
1247
|
+
implies(isAdmin, expr`${Clearance} >= 4`),
|
|
1248
|
+
"admin_clearance_level"
|
|
1249
|
+
);
|
|
1250
|
+
|
|
1251
|
+
// Top-level helper functions (and, or, not, implies, xor):
|
|
1252
|
+
m.constraint(and(or(isAdmin, hasMfa), not(xor(isAdmin, hasMfa))), "policy");
|
|
1253
|
+
```
|
|
1254
|
+
|
|
1255
|
+
### Choosing Between `m.indicator()` and `implies()`
|
|
1256
|
+
|
|
1257
|
+
| Feature | `m.indicator(binVar, constraint, opts)` | `implies(a, b)` / `a.implies(b)` |
|
|
1258
|
+
| :--- | :--- | :--- |
|
|
1259
|
+
| **Trigger variable** | Numeric `Binary` variable (`m.binary()`) | `BoolVar` (`m.bool()`), `BoolOp`, or `Constraint` |
|
|
1260
|
+
| **Consequent** | Linear `Constraint` (`<=`, `>=`, `==`) | `BoolVar`, `BoolOp`, or linear `Constraint` |
|
|
1261
|
+
| **Target Solvers** | **All MIP/LP solvers** (HiGHS, GLPK, Z3) via Big-M linearization | **All solvers** (HiGHS and GLPK via automatic 0-1 MIP linearization, Z3 natively) |
|
|
1262
|
+
| **Best For** | Operations research & MIP optimization with an objective (`m.solve()`) | Rule engines, policy verification, and SMT feasibility (`m.check()`) |
|
|
1263
|
+
|
|
1264
|
+
---
|
|
1265
|
+
|
|
1266
|
+
## 📐 Dual Values, Shadow Prices & Lookups
|
|
1267
|
+
|
|
1268
|
+
For continuous Linear Programming (LP) problems, query the economic shadow price (`dual`) and surplus (`slack`) on each constraint:
|
|
1269
|
+
|
|
1270
|
+
```typescript
|
|
1271
|
+
m.constraint(expr`${x} + 2 * ${y} <= 40`, "wood");
|
|
1272
|
+
|
|
1273
|
+
await m.solve();
|
|
1274
|
+
|
|
1275
|
+
const woodConstraint = m.getConstraintByName("wood");
|
|
1276
|
+
console.log("Shadow price (dual):", woodConstraint?.dual);
|
|
1277
|
+
console.log("Slack surplus:", woodConstraint?.slack);
|
|
1278
|
+
console.log("Total linear constraints:", m.numConstrs);
|
|
1279
|
+
```
|
|
1280
|
+
|
|
1281
|
+
For logical constraints registered with a name, use `m.getLogicalConstraintByName(name)` to look up the registered `LogicalConstraintItem`.
|
|
1282
|
+
|
|
1283
|
+
---
|
|
1284
|
+
|
|
1285
|
+
# Objectives & Solving (`m.solve` & `m.check`)
|
|
1286
|
+
|
|
1287
|
+
The objective function defines the goal of mathematical optimization—whether to maximize profits, minimize costs, or minimize quadratic risk variance. When you only need to verify constraint feasibility or solve pure SMT logic, you can also run `m.check()` without an objective.
|
|
1288
|
+
|
|
1289
|
+
---
|
|
1290
|
+
|
|
1291
|
+
## Setting the Objective Function
|
|
1292
|
+
|
|
1293
|
+
Set the objective direction and formula declaratively using `m.objective(maximize(...))` or `m.objective(minimize(...))`:
|
|
1294
|
+
|
|
1295
|
+
```typescript
|
|
1296
|
+
import { Model, expr, maximize, minimize } from "@ohtaman/washi";
|
|
1297
|
+
|
|
1298
|
+
const m = new Model("ProductionRevenue");
|
|
1299
|
+
const x = m.integer("x", { lb: 0, ub: 40 });
|
|
1300
|
+
const y = m.integer("y", { lb: 0, ub: 40 });
|
|
1301
|
+
|
|
1302
|
+
// Maximize revenue: 50*x + 40*y subject to resource capacity
|
|
1303
|
+
m.objective(maximize(expr`50 * ${x} + 40 * ${y}`));
|
|
1304
|
+
m.constraint(expr`2 * ${x} + 3 * ${y} <= 100`, "resource_limit");
|
|
1305
|
+
|
|
1306
|
+
// Or minimize cost:
|
|
1307
|
+
// m.objective(minimize(expr`10 * ${x} + 15 * ${y}`));
|
|
1308
|
+
```
|
|
1309
|
+
|
|
1310
|
+
Calling `m.objective()` with no arguments returns the current objective expression (`LinExpr | QuadExpr`). If you omit setting an objective, the model defaults to minimizing `0`.
|
|
1311
|
+
|
|
1312
|
+
---
|
|
1313
|
+
|
|
1314
|
+
## 🚀 Solving the Model (`m.solve()` vs. `m.check()`)
|
|
1315
|
+
|
|
1316
|
+
WaSHI provides two asynchronous execution entrypoints:
|
|
1317
|
+
|
|
1318
|
+
| Method | Return Value | Purpose |
|
|
1319
|
+
| :--- | :--- | :--- |
|
|
1320
|
+
| **`await m.solve(options?)`** | `Promise<SolverStatus>` | Optimizes the objective (`Minimize` or `Maximize`) subject to all constraints. Populates primal values, objective value, MIP bounds/gaps, and LP duals/slacks. |
|
|
1321
|
+
| **`await m.check(options?)`** | `Promise<CheckResult>` | Checks constraint satisfiability without an objective. Returns `{ status, values, conflicts?, unsatCore?, runTime? }` where `values: Record<string, number \| boolean>` contains both numeric and Boolean variable assignments. Ideal for SMT/SAT verification and Z3 `unsatCore` extraction. |
|
|
1322
|
+
|
|
1323
|
+
```typescript
|
|
1324
|
+
const status = await m.solve({
|
|
1325
|
+
timeLimit: 10.0, // Timeout in seconds
|
|
1326
|
+
mipGap: 0.01, // Stop within 1% of global optimum
|
|
1327
|
+
outputFlag: false, // Enable/disable solver console logging (HiGHS: outputFlag, GLPK: verbose)
|
|
1328
|
+
});
|
|
1329
|
+
```
|
|
1330
|
+
|
|
1331
|
+
### Solve Options (`SolveOptions`)
|
|
1332
|
+
|
|
1333
|
+
| Option | Type | Default | Description |
|
|
1334
|
+
| :--- | :--- | :--- | :--- |
|
|
1335
|
+
| `solver` | `string` \| `Solver` | Model's solver (`"highs"`) | Registered solver name (e.g. `"highs"`, `"z3"`, `"glpk"`, or custom) or `Solver` instance for this run. |
|
|
1336
|
+
| `timeLimit` | `number` | `undefined` | Maximum solve duration in seconds. |
|
|
1337
|
+
| `mipGap` / `relGap` | `number` | `undefined` | Relative MIP optimality gap tolerance (e.g. `0.01` for 1%). |
|
|
1338
|
+
| `absGap` | `number` | `undefined` | Absolute MIP optimality gap tolerance (HiGHS `mip_abs_gap`). |
|
|
1339
|
+
| `presolve` | `"on"` \| `"off"` \| `"choose"` | `"choose"` | Controls solver presolve reduction prior to optimization. |
|
|
1340
|
+
| `outputFlag` / `verbose` | `boolean` | `false` | Emits internal solver iteration logs (`outputFlag` for HiGHS, `verbose` for GLPK). |
|
|
1341
|
+
| `useWorker` | `boolean` | `true` (browser) | Offloads HiGHS to a dedicated Web Worker in browsers. Set `false` for main-thread execution. |
|
|
1342
|
+
| `wasmUrl` | `string` | Auto-resolved | Custom URL to load `highs.wasm` in browser deployments. |
|
|
1343
|
+
| `signal` | `AbortSignal` | `undefined` | Cooperative cancellation signal (`AbortController.signal`). |
|
|
1344
|
+
| `onProgress` | `(e: SolveProgressEvent) => void` | `undefined` | Callback receiving live solver progress snapshots. |
|
|
1345
|
+
|
|
1346
|
+
You can also pass a solver name directly as the first argument: `await m.solve("glpk", { timeLimit: 5 })`.
|
|
1347
|
+
|
|
1348
|
+
---
|
|
1349
|
+
|
|
1350
|
+
## ⏱️ Real-Time Progress (`onProgress`) & Cancellation (`AbortSignal`)
|
|
1351
|
+
|
|
1352
|
+
For long-running MIP optimizations in interactive UIs, you can stream live branch-and-bound progress (`SolveProgressEvent`) and allow users to cancel the solve cleanly with `AbortController`:
|
|
1353
|
+
|
|
1354
|
+
```typescript
|
|
1355
|
+
const controller = new AbortController();
|
|
1356
|
+
|
|
1357
|
+
// Example: cancel if the user clicks a "Stop" button or after a custom condition
|
|
1358
|
+
const status = await m.solve({
|
|
1359
|
+
signal: controller.signal,
|
|
1360
|
+
onProgress: (event) => {
|
|
1361
|
+
console.log(
|
|
1362
|
+
`[${event.elapsedTime.toFixed(1)}s] ` +
|
|
1363
|
+
`Best: ${event.objectiveValue ?? "—"}, ` +
|
|
1364
|
+
`Bound: ${event.objectiveBound ?? "—"}, ` +
|
|
1365
|
+
`Gap: ${event.mipGap !== undefined ? (event.mipGap * 100).toFixed(2) + "%" : "—"}`
|
|
1366
|
+
);
|
|
1367
|
+
|
|
1368
|
+
// Optional: stop early as soon as a good-enough incumbent is found
|
|
1369
|
+
if (event.objectiveValue !== undefined && event.objectiveValue >= 1000) {
|
|
1370
|
+
controller.abort();
|
|
1371
|
+
}
|
|
1372
|
+
},
|
|
1373
|
+
});
|
|
1374
|
+
|
|
1375
|
+
console.log("Status:", m.status); // "Feasible" (if an incumbent was found before abort) or "Unknown"
|
|
1376
|
+
console.log("Termination:", m.terminationStatus); // "Interrupted"
|
|
1377
|
+
console.log("Has incumbent:", m.hasPrimalSolution); // true if an incumbent was available
|
|
1378
|
+
```
|
|
1379
|
+
|
|
1380
|
+
### `SolveProgressEvent` Fields
|
|
1381
|
+
|
|
1382
|
+
| Field | Type | Description |
|
|
1383
|
+
| :--- | :--- | :--- |
|
|
1384
|
+
| `elapsedTime` | `number` | Elapsed solver time in seconds (optional alias: `elapsedSeconds`). |
|
|
1385
|
+
| `phase` | `"simplex" \| "ipm" \| "mip" \| undefined` | Current solver phase when known. |
|
|
1386
|
+
| `objectiveValue` | `number \| undefined` | Current best feasible incumbent objective value (MIP). |
|
|
1387
|
+
| `objectiveBound` | `number \| undefined` | Current best proven dual / objective bound (MIP). |
|
|
1388
|
+
| `relGap` / `mipGap` | `number \| undefined` | Current relative MIP optimality gap (`0.05` = 5%). |
|
|
1389
|
+
| `absGap` | `number \| undefined` | Current absolute MIP optimality gap `\|objectiveValue - objectiveBound\|`. |
|
|
1390
|
+
| `nodes` | `number \| undefined` | Branch-and-bound nodes explored so far. |
|
|
1391
|
+
| `iterations` | `number \| undefined` | Solver iteration count (simplex, IPM, or MIP LP iterations). |
|
|
1392
|
+
| `message` | `string \| undefined` | Raw progress line emitted by the solver. |
|
|
1393
|
+
|
|
1394
|
+
---
|
|
1395
|
+
|
|
1396
|
+
## 📊 Reading Results, Bounds & Solver Status
|
|
1397
|
+
|
|
1398
|
+
After solving, query the status, optimal values, and MIP optimality bounds on the `Model`:
|
|
1399
|
+
|
|
1400
|
+
```typescript
|
|
1401
|
+
const status = await m.solve();
|
|
1402
|
+
|
|
1403
|
+
console.log("Status:", m.status); // e.g. "Optimal" or "Feasible"
|
|
1404
|
+
console.log("Termination Reason:", m.terminationStatus); // e.g. "Optimal", "TimeLimit", "Interrupted"
|
|
1405
|
+
console.log("Has Primal Solution:", m.hasPrimalSolution); // true when x.value / m.objectiveValue are populated
|
|
1406
|
+
|
|
1407
|
+
// Objective & MIP Gap Metrics
|
|
1408
|
+
console.log("Objective Value:", m.objectiveValue); // Best primal objective (or null)
|
|
1409
|
+
console.log("Objective Bound:", m.objectiveBound); // Best dual bound (or undefined)
|
|
1410
|
+
console.log("Relative Gap:", m.relGap); // Relative MIP gap (alias: m.mipGap)
|
|
1411
|
+
console.log("Absolute Gap:", m.absGap); // |objectiveValue - objectiveBound|
|
|
1412
|
+
|
|
1413
|
+
// Individual & Bulk Variable Values
|
|
1414
|
+
console.log("x =", x.value, "y =", y.value);
|
|
1415
|
+
console.log(m.getValues({ x, y })); // { x: ..., y: ... }
|
|
1416
|
+
```
|
|
1417
|
+
|
|
1418
|
+
### Understanding `m.status` vs. `m.terminationStatus`
|
|
1419
|
+
|
|
1420
|
+
When a MIP solver stops early due to a time limit, iteration limit, or `AbortSignal`, two distinct questions matter:
|
|
1421
|
+
1. **Is there a usable solution?** (`m.hasPrimalSolution` / `m.status`)
|
|
1422
|
+
2. **Why did the solver stop?** (`m.terminationStatus`)
|
|
1423
|
+
|
|
1424
|
+
If an integer-feasible incumbent was found before hitting `timeLimit` or `signal.abort()`, WaSHI sets `m.status = "Feasible"` (so you know `x.value` and `m.objectiveValue` hold a valid solution) and sets `m.terminationStatus = "TimeLimit"` (or `"Interrupted"`). If no incumbent was found before stopping, `m.hasPrimalSolution` is `false`, `m.terminationStatus` records why the solver stopped (`"TimeLimit"`, `"IterationLimit"`, or `"Interrupted"`), and `m.status` is `"TimeLimit"`, `"IterationLimit"`, or `"Unknown"` (when aborted via `AbortSignal` without an incumbent).
|
|
1425
|
+
|
|
1426
|
+
### All `SolverStatus` Values
|
|
1427
|
+
|
|
1428
|
+
| Status | `hasPrimalSolution` | Meaning |
|
|
1429
|
+
| :--- | :--- | :--- |
|
|
1430
|
+
| `"Optimal"` | `true` | Global optimal solution proven (within tolerances). |
|
|
1431
|
+
| `"Feasible"` | `true` | Valid primal solution (incumbent) found, but optimality was not proven before stopping (`terminationStatus` indicates why: `"TimeLimit"`, `"IterationLimit"`, `"Interrupted"`, etc.). |
|
|
1432
|
+
| `"Sat"` | `true` | Constraints are satisfiable (`m.check()`). |
|
|
1433
|
+
| `"Infeasible"` | `false` | Proven mathematically infeasible; no solution satisfies all constraints. |
|
|
1434
|
+
| `"Unsat"` | `false` | Logical/arithmetic constraints are unsatisfiable (`m.check()`). |
|
|
1435
|
+
| `"Unbounded"` | `false` | Objective can improve indefinitely without violating constraints. |
|
|
1436
|
+
| `"TimeLimit"` | `false` | Time limit reached before finding any feasible primal solution. |
|
|
1437
|
+
| `"IterationLimit"` | `false` | Iteration limit reached before finding any feasible primal solution. |
|
|
1438
|
+
| `"Unknown"` | `false` | Solver stopped inconclusively without a valid primal solution (including when cancelled via `AbortSignal` before finding an incumbent, where `terminationStatus === "Interrupted"`). |
|
|
1439
|
+
| `"Error"` | `false` | Solver encountered an internal execution error. |
|
|
1440
|
+
| `"NotSolved"` | `false` | Initial state before solving, or after the model has been mutated. |
|
|
1441
|
+
|
|
1442
|
+
---
|
|
1443
|
+
|
|
1444
|
+
## 🔄 Model Mutations & Execution Validity
|
|
1445
|
+
|
|
1446
|
+
A `Model` instance keeps its solution state synchronized across mutations made through `Model` methods and `Model` / `Var` / `Constraint` property setters:
|
|
1447
|
+
|
|
1448
|
+
1. **Post-Solve Mutations Reset Solution State**: Adding variables or constraints, updating the objective (`m.objective(...)`, `m.objectiveExpr = ...`, `m.sense = ...`), or modifying variable bounds/types (`v.lb`, `v.ub`, `v.type`) or constraint properties (`c.lhs`, `c.sense`, `c.rhs`) immediately resets `m.status` to `"NotSolved"`, clears `m.solution` and `m.objectiveValue`, and resets variable/constraint primal and dual values to `null`.
|
|
1449
|
+
2. **Mid-Flight Mutations Discard Stale Runs**: If the model is modified via these APIs while an asynchronous `m.solve()` or `m.check()` call is in progress, the stale solver result is discarded, the model remains in `"NotSolved"`, and the in-flight promise rejects with an explicit error.
|
|
1450
|
+
3. **Overlapping Executions**: Starting a new `m.solve()` or `m.check()` call supersedes any earlier in-flight call on the same model so out-of-order completions can never overwrite the latest run's state.
|
|
1451
|
+
4. **Transactional Batch Edits**: If an invalid edit is rejected (including batch array calls like `m.constraint([...])`), the model rolls back the failed edit without leaving partial entities registered or invalidating an existing valid solution.
|
|
1452
|
+
|
|
1453
|
+
> [!NOTE]
|
|
1454
|
+
> Renaming already-registered variables or constraints (e.g., reassigning `c.name` after registration) or mutating internal backing collections and ASTs directly (such as `c.lhs.terms`, `m.constraints.splice(...)`, or `BoolOp.children`) is unsupported and will be rejected by model integrity validation on the next solve/check/export call.
|
|
1455
|
+
|
|
1456
|
+
---
|
|
1457
|
+
|
|
1458
|
+
# Conflict Diagnosis (`unsatCore`) & Solution Validation
|
|
1459
|
+
|
|
1460
|
+
When building real-world optimization and configuration systems, two verification questions arise constantly:
|
|
1461
|
+
1. **Why is my model infeasible?** Which specific business rules contradict each other?
|
|
1462
|
+
2. **Does the solver's solution genuinely satisfy every constraint?** How do I verify tolerances, integrality, and formulations?
|
|
1463
|
+
|
|
1464
|
+
WaSHI provides built-in tools for both: **Unsat Core conflict diagnosis** via Z3 (`m.check()`), **independent solution validation** (`m.validateSolution()`), **LP formulation export** (`m.toLpString()`), and **fast unit testing** (`MockSolver`).
|
|
1465
|
+
|
|
1466
|
+
---
|
|
1467
|
+
|
|
1468
|
+
## 🔍 Conflict Diagnosis (`unsatCore` / `conflicts`)
|
|
1469
|
+
|
|
1470
|
+
When a model is **Infeasible**, traditional MIP solvers simply report that no solution exists without indicating *which* constraints are contradictory.
|
|
1471
|
+
By calling **`await m.check({ solver: "z3" })`**, WaSHI uses Z3's `assertAndTrack` mechanism to compute an **Unsatisfiable Core (`unsatCore`)**—a conflicting subset of named constraints that cannot be satisfied simultaneously:
|
|
1472
|
+
|
|
1473
|
+
```typescript
|
|
1474
|
+
import { Model, expr } from "@ohtaman/washi";
|
|
1475
|
+
// Register the Z3 solver plugin
|
|
1476
|
+
import "@ohtaman/washi/solvers/z3";
|
|
1477
|
+
|
|
1478
|
+
const m = new Model();
|
|
1479
|
+
|
|
1480
|
+
const x = m.continuous("reserve", { lb: 0 });
|
|
1481
|
+
|
|
1482
|
+
// Add named business requirements
|
|
1483
|
+
m.constraint(expr`${x} >= 100`, "minimum_regulatory_reserve");
|
|
1484
|
+
m.constraint(expr`${x} <= 50`, "budget_ceiling");
|
|
1485
|
+
m.constraint(expr`${x} >= 10`, "routine_operating_cost");
|
|
1486
|
+
|
|
1487
|
+
const result = await m.check({ solver: "z3" });
|
|
1488
|
+
|
|
1489
|
+
if (result.status === "Unsat") {
|
|
1490
|
+
console.log("Contradictory constraints identified:");
|
|
1491
|
+
console.log(m.conflicts); // or result.conflicts / result.unsatCore / m.unsatCore
|
|
1492
|
+
// Output: ["minimum_regulatory_reserve", "budget_ceiling"]
|
|
1493
|
+
}
|
|
1494
|
+
```
|
|
1495
|
+
|
|
1496
|
+
Notice that `routine_operating_cost` is **not** in the core, because only `minimum_regulatory_reserve` ($x \ge 100$) and `budget_ceiling` ($x \le 50$) directly contradict each other.
|
|
1497
|
+
|
|
1498
|
+
> [!IMPORTANT]
|
|
1499
|
+
> - **Use `m.check({ solver: "z3" })` for Unsat Core extraction**: `m.solve()` runs optimization (`Z3.Optimize`), whereas `m.check()` runs tracked satisfiability checking (`Z3.Solver.assertAndTrack`) and populates `result.conflicts` / `m.conflicts` (and alias `m.unsatCore`).
|
|
1500
|
+
> - **Name your constraints**: Variable bounds (`lb`, `ub`) act as background domain declarations. To include a bound or business rule in the reported `unsatCore` list, register it as a named constraint via `m.constraint(expr`...`, "rule_name")`.
|
|
1501
|
+
|
|
1502
|
+
### Workflow: Infeasibility Diagnosis with HiGHS or Z3
|
|
1503
|
+
|
|
1504
|
+
Because WaSHI models are solver-independent, you can diagnose infeasibilities using either solver:
|
|
1505
|
+
1. **Built-in HiGHS & GLPK (default)**: `await m.check()` uses a single-solve elastic slack formulation ($\min \sum s_i$) to identify which constraints required relaxation (`diag.conflicts`), requiring no extra dependencies.
|
|
1506
|
+
2. **Z3 SMT Solver**: `await m.check({ solver: "z3" })` uses SMT tracking to extract an exact unsatisfiable core (`diag.unsatCore` / `diag.conflicts`).
|
|
1507
|
+
|
|
1508
|
+
```typescript
|
|
1509
|
+
const status = await m.solve(); // Fast MIP solve with default HiGHS
|
|
1510
|
+
|
|
1511
|
+
if (status === "Infeasible") {
|
|
1512
|
+
// Option 1: Built-in single-solve elastic diagnosis with HiGHS
|
|
1513
|
+
const diag = await m.check();
|
|
1514
|
+
console.error("Model is infeasible due to conflicting rules:", diag.conflicts);
|
|
1515
|
+
|
|
1516
|
+
// Option 2: Exact SMT unsat core diagnosis with Z3 (requires import "@ohtaman/washi/solvers/z3")
|
|
1517
|
+
// const z3Diag = await m.check({ solver: "z3" });
|
|
1518
|
+
// console.error("Z3 Unsat Core:", z3Diag.unsatCore);
|
|
1519
|
+
}
|
|
1520
|
+
```
|
|
1521
|
+
|
|
1522
|
+
---
|
|
1523
|
+
|
|
1524
|
+
## ✅ Independent Solution Validation (`m.validateSolution()`)
|
|
1525
|
+
|
|
1526
|
+
After solving a model (or supplying a custom candidate map via `values`), you can independently verify that all variable bounds, integer/binary domains, linear constraints, and logical SMT formulas hold against the original model definitions:
|
|
1527
|
+
|
|
1528
|
+
```typescript
|
|
1529
|
+
const status = await m.solve();
|
|
1530
|
+
|
|
1531
|
+
if (m.hasPrimalSolution) {
|
|
1532
|
+
const report = m.validateSolution({
|
|
1533
|
+
feasibilityTolerance: 1e-6, // Max allowed violation for bounds and linear constraints
|
|
1534
|
+
integralityTolerance: 1e-6, // Max distance from nearest integer for Integer/Binary vars
|
|
1535
|
+
evaluateLogicalConstraints: true,
|
|
1536
|
+
});
|
|
1537
|
+
|
|
1538
|
+
console.log("Valid:", report.valid);
|
|
1539
|
+
console.log("Complete:", report.complete);
|
|
1540
|
+
console.log("Max violation:", report.maxViolation);
|
|
1541
|
+
console.log("Summary:", report.summary);
|
|
1542
|
+
|
|
1543
|
+
if (!report.valid) {
|
|
1544
|
+
console.warn("Violations detected:", report.violations);
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
```
|
|
1548
|
+
|
|
1549
|
+
### `SolutionValidationReport` Summary
|
|
1550
|
+
|
|
1551
|
+
| Property | Type | Description |
|
|
1552
|
+
| :--- | :--- | :--- |
|
|
1553
|
+
| `valid` | `boolean` | `true` iff `complete === true`, `uncheckedConstraints.length === 0`, and `violations.length === 0`. |
|
|
1554
|
+
| `complete` | `boolean` | `true` when all variables, Boolean variables, linear constraints, and logical constraints were evaluated. |
|
|
1555
|
+
| `maxViolation` | `number` | Largest numeric violation magnitude across all evaluated bounds, integrality checks, and constraints. |
|
|
1556
|
+
| `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"`). |
|
|
1557
|
+
| `violationsByName` | `Record<string, SolutionViolation[]>` | Violations grouped by variable or constraint name. |
|
|
1558
|
+
| `uncheckedConstraints` | `string[]` | Names of constraints that could not be evaluated. |
|
|
1559
|
+
| `missingVariables` | `string[]` | Names of numeric or Boolean variables whose values were `null` or `undefined`. |
|
|
1560
|
+
| `summary` | `SolutionValidationSummary` | Counts of checked vs. total variables, Boolean variables, linear constraints, and logical constraints. |
|
|
1561
|
+
|
|
1562
|
+
---
|
|
1563
|
+
|
|
1564
|
+
## 📄 Inspecting the LP Formulation (`m.toLpString()`)
|
|
1565
|
+
|
|
1566
|
+
When debugging coefficients, signs, or Big-M indicator expansions, call `m.toLpString()` to export the model in standard CPLEX/HiGHS `.lp` format:
|
|
1567
|
+
|
|
1568
|
+
```typescript
|
|
1569
|
+
const m = new Model("DebugModel");
|
|
1570
|
+
const x = m.integer("x", { lb: 0, ub: 10 });
|
|
1571
|
+
m.objective(maximize(expr`5 * ${x}`));
|
|
1572
|
+
m.constraint(expr`2 * ${x} <= 15`, "resource_limit");
|
|
1573
|
+
|
|
1574
|
+
console.log(m.toLpString());
|
|
1575
|
+
/*
|
|
1576
|
+
Maximize
|
|
1577
|
+
obj: 5 x
|
|
1578
|
+
Subject To
|
|
1579
|
+
resource_limit: 2 x <= 15
|
|
1580
|
+
Bounds
|
|
1581
|
+
0 <= x <= 10
|
|
1582
|
+
Generals
|
|
1583
|
+
x
|
|
1584
|
+
End
|
|
1585
|
+
*/
|
|
1586
|
+
```
|
|
1587
|
+
|
|
1588
|
+
---
|
|
1589
|
+
|
|
1590
|
+
## 🧪 Unit Testing Application Logic with `MockSolver`
|
|
1591
|
+
|
|
1592
|
+
When running unit tests for application code that builds or consumes a WaSHI `Model`, you can use the built-in `MockSolver` (`solver: "mock"`) to return deterministic results instantaneously without loading WebAssembly:
|
|
1593
|
+
|
|
1594
|
+
```typescript
|
|
1595
|
+
import { Model, MockSolver, expr, maximize } from "@ohtaman/washi";
|
|
1596
|
+
|
|
1597
|
+
const mock = new MockSolver({
|
|
1598
|
+
status: "Optimal",
|
|
1599
|
+
objectiveValue: 420,
|
|
1600
|
+
values: { x: 10, y: 20 },
|
|
1601
|
+
});
|
|
1602
|
+
|
|
1603
|
+
const m = new Model({ solver: mock });
|
|
1604
|
+
const x = m.integer("x");
|
|
1605
|
+
const y = m.integer("y");
|
|
1606
|
+
m.objective(maximize(expr`10 * ${x} + 16 * ${y}`));
|
|
1607
|
+
|
|
1608
|
+
await m.solve();
|
|
1609
|
+
console.log(m.objectiveValue); // 420
|
|
1610
|
+
console.log(x.value, y.value); // 10, 20
|
|
1611
|
+
```
|
|
1612
|
+
|
|
1613
|
+
---
|
|
1614
|
+
|
|
1615
|
+
# Advanced Decomposition Methods & Best Practices
|
|
1616
|
+
|
|
1617
|
+
Mixed-Integer Programming (MIP) formulations can become slow when symmetry or coupling constraints cause branch-and-bound trees to grow large.
|
|
1618
|
+
WaSHI provides decomposition orchestrators (`master.solveColumnGeneration` and `master.solveBenders`) along with lower-level modeling primitives for dual inspection and dynamic model modification.
|
|
1619
|
+
|
|
1620
|
+
---
|
|
1621
|
+
|
|
1622
|
+
## Why Decomposition? (When Monolithic MIP Stalls)
|
|
1623
|
+
|
|
1624
|
+
There are two common problem structures where monolithic MIP formulations struggle:
|
|
1625
|
+
|
|
1626
|
+
| Problem Structure | Monolithic MIP Bottleneck | Technique | WaSHI API |
|
|
1627
|
+
| :--- | :--- | :--- | :--- |
|
|
1628
|
+
| **Symmetry & Loose Continuous Bounds**<br>*(Cutting Stock, Vehicle Routing/VRP, Crew Rostering)* | Symmetric permutations and a weak continuous relaxation enlarge the branch-and-bound tree. | **Column Generation**<br>*(Dantzig-Wolfe Decomposition)* | [`master.solveColumnGeneration()`](#column-generation-solvecolumngeneration) |
|
|
1629
|
+
| **Complicating Decisions & Multi-Stage Structure**<br>*(Traveling Salesperson/TSP, Facility Location with Routing, Two-Stage Stochastic Planning)* | Coupling master decisions with large scenario or operational subproblems increases the matrix size. | **Benders Decomposition** | [`master.solveBenders()`](#benders-decomposition-solvebenders) |
|
|
1630
|
+
|
|
1631
|
+
---
|
|
1632
|
+
|
|
1633
|
+
## Foundational Primitives
|
|
1634
|
+
|
|
1635
|
+
WaSHI provides fine-grained modeling primitives to inspect dual information and dynamically modify models:
|
|
1636
|
+
|
|
1637
|
+
### 1. Constraint Duals (`dual`)
|
|
1638
|
+
Access the dual values (marginal impact on the objective per unit change in RHS) after an LP solve:
|
|
1639
|
+
|
|
1640
|
+
```typescript
|
|
1641
|
+
m.constraint(expr`${x} + ${y} <= 10`, "capacity");
|
|
1642
|
+
await m.solve();
|
|
1643
|
+
|
|
1644
|
+
const c = m.getConstraintByName("capacity")!;
|
|
1645
|
+
console.log(c.dual); // Dual value / shadow price
|
|
1646
|
+
console.log(m.solution?.duals.capacity);
|
|
1647
|
+
```
|
|
1648
|
+
|
|
1649
|
+
### 2. Continuous LP Relaxation (`model.relax()`)
|
|
1650
|
+
Decomposition algorithms require solving the continuous LP relaxation to extract dual values. `model.relax()` temporarily relaxes all `Integer` and `Binary` variables to continuous variables within their bounds:
|
|
1651
|
+
|
|
1652
|
+
```typescript
|
|
1653
|
+
// Solves LP relaxation and populates shadow prices in m.solution.duals
|
|
1654
|
+
const status = await m.relax();
|
|
1655
|
+
console.log("Theoretical dual bound:", m.objectiveValue);
|
|
1656
|
+
```
|
|
1657
|
+
|
|
1658
|
+
### 3. Dynamic Column Injection (`model.addColumn()`)
|
|
1659
|
+
Inject a new decision variable along with its objective coefficient and constraint contributions in a single call:
|
|
1660
|
+
|
|
1661
|
+
```typescript
|
|
1662
|
+
const col = m.addColumn({
|
|
1663
|
+
name: "pattern_1",
|
|
1664
|
+
type: "Integer", // or "Continuous"
|
|
1665
|
+
lb: 0,
|
|
1666
|
+
cost: 1, // objective coefficient
|
|
1667
|
+
coefficients: {
|
|
1668
|
+
demand_itemA: 2,
|
|
1669
|
+
demand_itemB: 1,
|
|
1670
|
+
},
|
|
1671
|
+
});
|
|
1672
|
+
```
|
|
1673
|
+
|
|
1674
|
+
### 4. Dynamic Constraint Modification (`addTerm` / `setTerm`)
|
|
1675
|
+
Update the coefficients of individual variables in an existing constraint:
|
|
1676
|
+
|
|
1677
|
+
```typescript
|
|
1678
|
+
const c = m.getConstraintByName("budget")!;
|
|
1679
|
+
c.addTerm(newVar, 5); // Increments newVar's coefficient by 5
|
|
1680
|
+
c.setTerm(newVar, 0); // Setting to 0 cleanly removes the term
|
|
1681
|
+
```
|
|
1682
|
+
|
|
1683
|
+
---
|
|
1684
|
+
|
|
1685
|
+
## Column Generation (`solveColumnGeneration`)
|
|
1686
|
+
|
|
1687
|
+
Column Generation starts with a **Restricted Master Problem (RMP)** with a small subset of columns. In each iteration:
|
|
1688
|
+
1. It solves the RMP's LP relaxation via `master.relax()`.
|
|
1689
|
+
2. Evaluates a pricing subproblem using constraint shadow prices.
|
|
1690
|
+
3. Finds candidate columns with negative reduced costs ($\bar{c} = \text{cost} - \sum \pi_i a_i < 0$) and dynamically injects them into the master problem.
|
|
1691
|
+
4. Repeats until no improving columns exist.
|
|
1692
|
+
5. Finally solves the full master model as a Mixed-Integer Program (`finalizeAsMIP`, default: `true`) to obtain optimal integer solutions.
|
|
1693
|
+
|
|
1694
|
+
### Column Generation with the `pricing` Callback
|
|
1695
|
+
|
|
1696
|
+
Provide a `pricing` callback that receives constraint shadow prices (duals), solves the subproblem (using a WaSHI model or any custom algorithm), and returns new column proposals. WaSHI automatically evaluates reduced costs and injects improving columns:
|
|
1697
|
+
|
|
1698
|
+
```typescript
|
|
1699
|
+
import { Model, expr, minimize, maximize } from "@ohtaman/washi";
|
|
1700
|
+
|
|
1701
|
+
const rollWidth = 100;
|
|
1702
|
+
const items = [
|
|
1703
|
+
{ id: "A", width: 45, demand: 10 },
|
|
1704
|
+
{ id: "B", width: 36, demand: 14 },
|
|
1705
|
+
{ id: "C", width: 16, demand: 20 },
|
|
1706
|
+
];
|
|
1707
|
+
|
|
1708
|
+
// 1. Define Restricted Master Problem (RMP) with naive initial single-cut patterns
|
|
1709
|
+
const master = new Model("CuttingStockMaster");
|
|
1710
|
+
|
|
1711
|
+
const pA = master.integer("init_A", { lb: 0 }); // 2 of A per roll (45*2 = 90 <= 100)
|
|
1712
|
+
const pB = master.integer("init_B", { lb: 0 }); // 2 of B per roll (36*2 = 72 <= 100)
|
|
1713
|
+
const pC = master.integer("init_C", { lb: 0 }); // 6 of C per roll (16*6 = 96 <= 100)
|
|
1714
|
+
|
|
1715
|
+
master.objective(minimize(expr`${pA} + ${pB} + ${pC}`));
|
|
1716
|
+
master.constraint(expr`2 * ${pA} >= 10`, "demand_A");
|
|
1717
|
+
master.constraint(expr`2 * ${pB} >= 14`, "demand_B");
|
|
1718
|
+
master.constraint(expr`6 * ${pC} >= 20`, "demand_C");
|
|
1719
|
+
const [demandA, demandB, demandC] = master.constraints;
|
|
1720
|
+
|
|
1721
|
+
// 2. Solve with master.solveColumnGeneration()
|
|
1722
|
+
const result = await master.solveColumnGeneration({
|
|
1723
|
+
finalizeAsMIP: true,
|
|
1724
|
+
pricing: async () => {
|
|
1725
|
+
// Pricing subproblem: Bounded Knapsack
|
|
1726
|
+
const sub = new Model("KnapsackPricing");
|
|
1727
|
+
const a_A = sub.integer("a_A", { lb: 0, ub: 2 });
|
|
1728
|
+
const a_B = sub.integer("a_B", { lb: 0, ub: 2 });
|
|
1729
|
+
const a_C = sub.integer("a_C", { lb: 0, ub: 6 });
|
|
1730
|
+
|
|
1731
|
+
// Maximize shadow price collection
|
|
1732
|
+
const dualA = demandA.dual ?? 0;
|
|
1733
|
+
const dualB = demandB.dual ?? 0;
|
|
1734
|
+
const dualC = demandC.dual ?? 0;
|
|
1735
|
+
sub.objective(maximize(expr`${dualA} * ${a_A} + ${dualB} * ${a_B} + ${dualC} * ${a_C}`));
|
|
1736
|
+
sub.constraint(expr`45 * ${a_A} + 36 * ${a_B} + 16 * ${a_C} <= ${rollWidth}`);
|
|
1737
|
+
|
|
1738
|
+
await sub.solve();
|
|
1739
|
+
|
|
1740
|
+
// Return the generated cutting pattern
|
|
1741
|
+
return {
|
|
1742
|
+
cost: 1,
|
|
1743
|
+
type: "Integer",
|
|
1744
|
+
coefficients: {
|
|
1745
|
+
demand_A: Math.round(a_A.value ?? 0),
|
|
1746
|
+
demand_B: Math.round(a_B.value ?? 0),
|
|
1747
|
+
demand_C: Math.round(a_C.value ?? 0),
|
|
1748
|
+
},
|
|
1749
|
+
};
|
|
1750
|
+
},
|
|
1751
|
+
});
|
|
1752
|
+
|
|
1753
|
+
console.log("Optimal rolls needed:", result.objectiveValue); // 14
|
|
1754
|
+
console.log("Iterations executed:", result.iterations);
|
|
1755
|
+
console.log("Dynamic columns added:", result.columnsAdded);
|
|
1756
|
+
```
|
|
1757
|
+
|
|
1758
|
+
### Custom Pricing Algorithms (Graph / Dynamic Programming)
|
|
1759
|
+
|
|
1760
|
+
Because `pricing` is a standard callback returning column definitions, you can solve the subproblem using any algorithm (e.g. Dijkstra, Bellman-Ford, or custom heuristics):
|
|
1761
|
+
|
|
1762
|
+
```typescript
|
|
1763
|
+
const result = await master.solveColumnGeneration({
|
|
1764
|
+
pricing: async ({ getDual }) => {
|
|
1765
|
+
// Solve subproblem with specialized custom graph algorithm
|
|
1766
|
+
const route = solveSpecializedShortestPath(getDual);
|
|
1767
|
+
if (!route) return null;
|
|
1768
|
+
|
|
1769
|
+
return {
|
|
1770
|
+
cost: route.distance,
|
|
1771
|
+
type: "Integer",
|
|
1772
|
+
coefficients: route.visitedCustomers,
|
|
1773
|
+
};
|
|
1774
|
+
},
|
|
1775
|
+
});
|
|
1776
|
+
```
|
|
1777
|
+
|
|
1778
|
+
---
|
|
1779
|
+
|
|
1780
|
+
## Benders Decomposition (`solveBenders`)
|
|
1781
|
+
|
|
1782
|
+
Benders Decomposition partitions the problem into:
|
|
1783
|
+
- **Master Problem**: Contains complicating variables (e.g., 0-1 facility opening decisions $y$) and an auxiliary variable $\theta \ge 0$ representing estimated subproblem costs.
|
|
1784
|
+
- **Subproblem**: Solves operational decisions given fixed master values $y^*$, feeding back **Optimality Cuts** to the master.
|
|
1785
|
+
|
|
1786
|
+
### Recommended: Automated Cut Generation with `sub.link()`
|
|
1787
|
+
|
|
1788
|
+
Return the subproblem `Model` from your callback and register linking constraints with `sub.link(...)`. WaSHI automatically manages $\theta$, evaluates master candidate values, extracts duals, and injects Benders optimality cuts:
|
|
1789
|
+
|
|
1790
|
+
```typescript
|
|
1791
|
+
import { Model, expr, minimize } from "@ohtaman/washi";
|
|
1792
|
+
|
|
1793
|
+
// 1. Master problem: Facility location decisions
|
|
1794
|
+
const master = new Model("FacilityMaster");
|
|
1795
|
+
const y1 = master.binary("open_F1");
|
|
1796
|
+
const y2 = master.binary("open_F2");
|
|
1797
|
+
|
|
1798
|
+
// Objective: Fixed facility costs (theta is managed automatically)
|
|
1799
|
+
master.objective(minimize(expr`100 * ${y1} + 120 * ${y2}`));
|
|
1800
|
+
// Minimal aggregate capacity requirement
|
|
1801
|
+
master.constraint(expr`50 * ${y1} + 60 * ${y2} >= 75`, "capacity_check");
|
|
1802
|
+
|
|
1803
|
+
// 2. Solve automatically with master.solveBenders()
|
|
1804
|
+
const result = await master.solveBenders({
|
|
1805
|
+
gapTolerance: 1e-4,
|
|
1806
|
+
subproblem: () => {
|
|
1807
|
+
// Operational dispatch subproblem
|
|
1808
|
+
const sub = new Model("TransportSubproblem");
|
|
1809
|
+
const x11 = sub.continuous("x_11", { lb: 0 });
|
|
1810
|
+
const x12 = sub.continuous("x_12", { lb: 0 });
|
|
1811
|
+
const x13 = sub.continuous("x_13", { lb: 0 });
|
|
1812
|
+
const x21 = sub.continuous("x_21", { lb: 0 });
|
|
1813
|
+
const x22 = sub.continuous("x_22", { lb: 0 });
|
|
1814
|
+
const x23 = sub.continuous("x_23", { lb: 0 });
|
|
1815
|
+
|
|
1816
|
+
sub.objective(
|
|
1817
|
+
minimize(
|
|
1818
|
+
expr`3 * ${x11} + 4 * ${x12} + 2 * ${x13} + 4 * ${x21} + 2 * ${x22} + 3 * ${x23}`
|
|
1819
|
+
)
|
|
1820
|
+
);
|
|
1821
|
+
|
|
1822
|
+
// Customer demand constraints
|
|
1823
|
+
sub.constraint(expr`${x11} + ${x21} >= 20`, "demand_C1");
|
|
1824
|
+
sub.constraint(expr`${x12} + ${x22} >= 25`, "demand_C2");
|
|
1825
|
+
sub.constraint(expr`${x13} + ${x23} >= 30`, "demand_C3");
|
|
1826
|
+
|
|
1827
|
+
// Link subproblem flows with master decisions y1, y2 via linking constraints
|
|
1828
|
+
sub.link(expr`${x11} + ${x12} + ${x13} <= 50 * ${y1}`);
|
|
1829
|
+
sub.link(expr`${x21} + ${x22} + ${x23} <= 60 * ${y2}`);
|
|
1830
|
+
|
|
1831
|
+
return sub; // Returning the model automatically generates cuts and manages theta
|
|
1832
|
+
},
|
|
1833
|
+
});
|
|
1834
|
+
|
|
1835
|
+
console.log("Optimal total cost:", result.objectiveValue); // 390
|
|
1836
|
+
console.log("Iterations:", result.iterations);
|
|
1837
|
+
console.log("Cuts added:", result.cutsAdded);
|
|
1838
|
+
console.log("Facility decisions:", {
|
|
1839
|
+
F1: result.values.open_F1,
|
|
1840
|
+
F2: result.values.open_F2,
|
|
1841
|
+
});
|
|
1842
|
+
```
|
|
1843
|
+
|
|
1844
|
+
### Low-Level Control: Custom `BendersCut` Callback
|
|
1845
|
+
|
|
1846
|
+
You can also construct Benders cuts manually by returning `BendersCut` objects:
|
|
1847
|
+
|
|
1848
|
+
```typescript
|
|
1849
|
+
const result = await master.solveBenders({
|
|
1850
|
+
gapTolerance: 1e-4,
|
|
1851
|
+
subproblem: async () => {
|
|
1852
|
+
const open1 = y1.value ?? 0;
|
|
1853
|
+
const open2 = y2.value ?? 0;
|
|
1854
|
+
const estTheta = theta.value ?? 0;
|
|
1855
|
+
|
|
1856
|
+
const sub = new Model("TransportSubproblem");
|
|
1857
|
+
// ...
|
|
1858
|
+
await sub.solve();
|
|
1859
|
+
|
|
1860
|
+
const actualCost = sub.objectiveValue!;
|
|
1861
|
+
if (estTheta < actualCost - 1e-4) {
|
|
1862
|
+
return {
|
|
1863
|
+
kind: "optimality",
|
|
1864
|
+
constraint: expr`${theta} - 50 * ${sub.solution!.duals.cap1} * ${y1} - 60 * ${sub.solution!.duals.cap2} * ${y2} >= ${rhs}`,
|
|
1865
|
+
subproblemObjective: 100 * open1 + 120 * open2 + actualCost,
|
|
1866
|
+
};
|
|
1867
|
+
}
|
|
1868
|
+
return null; // Converged
|
|
1869
|
+
},
|
|
1870
|
+
});
|
|
1871
|
+
```
|
|
1872
|
+
|
|
1873
|
+
---
|
|
1874
|
+
|
|
1875
|
+
## Application Examples
|
|
1876
|
+
|
|
1877
|
+
Two common applications of decomposition methods:
|
|
1878
|
+
|
|
1879
|
+
### 1. Vehicle Routing Problem (CVRP) $\times$ Column Generation
|
|
1880
|
+
*(Full executable source: [`examples/node/13-vehicle-routing-column-generation.ts`](https://github.com/ohtaman/washi/blob/main/examples/node/13-vehicle-routing-column-generation.ts))*
|
|
1881
|
+
|
|
1882
|
+
* **Problem Structure**: Enumerating every feasible customer tour upfront produces a large number of variables.
|
|
1883
|
+
* **Column Generation Formulation**:
|
|
1884
|
+
* **Master Problem**: Set covering model that selects the minimum number of vehicle routes such that each customer is served.
|
|
1885
|
+
* **Pricing Subproblem**: Knapsack model that dynamically generates a capacity-feasible route (column) with negative reduced cost based on customer dual shadow prices.
|
|
1886
|
+
|
|
1887
|
+
```typescript
|
|
1888
|
+
import { Model, expr, minimize, LinExpr, type Var } from "@ohtaman/washi";
|
|
1889
|
+
|
|
1890
|
+
const vehicleCapacity = 8;
|
|
1891
|
+
const customers = [
|
|
1892
|
+
{ id: "C1", demand: 4 },
|
|
1893
|
+
{ id: "C2", demand: 3 },
|
|
1894
|
+
{ id: "C3", demand: 5 },
|
|
1895
|
+
{ id: "C4", demand: 2 },
|
|
1896
|
+
{ id: "C5", demand: 4 },
|
|
1897
|
+
];
|
|
1898
|
+
|
|
1899
|
+
// 1. Master Problem: Customer coverage
|
|
1900
|
+
const master = new Model("CVRP_Master");
|
|
1901
|
+
const initialRoutes: Var[] = [];
|
|
1902
|
+
for (const cust of customers) {
|
|
1903
|
+
initialRoutes.push(master.integer(`init_${cust.id}`, { lb: 0, ub: 1 }));
|
|
1904
|
+
}
|
|
1905
|
+
master.objective(minimize(initialRoutes.reduce((acc, r) => acc.add(r), new LinExpr())));
|
|
1906
|
+
|
|
1907
|
+
for (let i = 0; i < customers.length; i++) {
|
|
1908
|
+
master.constraint(expr`${initialRoutes[i]} >= 1`, `visit_${customers[i].id}`);
|
|
1909
|
+
}
|
|
1910
|
+
const visitConstrs = master.constraints;
|
|
1911
|
+
|
|
1912
|
+
// 2. Solve via Column Generation
|
|
1913
|
+
const result = await master.solveColumnGeneration({
|
|
1914
|
+
finalizeAsMIP: true,
|
|
1915
|
+
pricing: async () => {
|
|
1916
|
+
const sub = new Model("RoutePricing");
|
|
1917
|
+
const a: Record<string, Var> = {};
|
|
1918
|
+
const obj = new LinExpr();
|
|
1919
|
+
|
|
1920
|
+
for (let i = 0; i < customers.length; i++) {
|
|
1921
|
+
const cust = customers[i];
|
|
1922
|
+
a[cust.id] = sub.binary(`v_${cust.id}`);
|
|
1923
|
+
obj.addTerm(a[cust.id], visitConstrs[i].dual ?? 0);
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
sub.objective(maximize(obj));
|
|
1927
|
+
|
|
1928
|
+
// Capacity limit: sum(demand_i * a_i) <= vehicleCapacity
|
|
1929
|
+
const load = customers.reduce((acc, c) => acc.add(a[c.id].mul(c.demand)), new LinExpr());
|
|
1930
|
+
sub.constraint(expr`${load} <= ${vehicleCapacity}`);
|
|
1931
|
+
|
|
1932
|
+
await sub.solve();
|
|
1933
|
+
|
|
1934
|
+
const coefficients: Record<string, number> = {};
|
|
1935
|
+
for (const cust of customers) {
|
|
1936
|
+
coefficients[`visit_${cust.id}`] = Math.round(a[cust.id].value ?? 0);
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
return {
|
|
1940
|
+
cost: 1,
|
|
1941
|
+
type: "Integer",
|
|
1942
|
+
coefficients,
|
|
1943
|
+
};
|
|
1944
|
+
},
|
|
1945
|
+
});
|
|
1946
|
+
|
|
1947
|
+
console.log("Optimal fleet size:", result.objectiveValue); // 3 vehicles
|
|
1948
|
+
```
|
|
1949
|
+
|
|
1950
|
+
### 2. Traveling Salesperson Problem (TSP) $\times$ Benders Decomposition (Lazy Subtour Elimination)
|
|
1951
|
+
*(Full executable source: [`examples/node/14-traveling-salesperson-benders.ts`](https://github.com/ohtaman/washi/blob/main/examples/node/14-traveling-salesperson-benders.ts))*
|
|
1952
|
+
|
|
1953
|
+
* **Problem Structure**: Preventing isolated sub-loops (subtours) requires Subtour Elimination Constraints (SECs), which grow as $O(2^n)$ constraints for $n$ cities.
|
|
1954
|
+
* **Benders Decomposition Formulation**:
|
|
1955
|
+
* **Master Problem**: Solves the relaxed assignment problem (each city has 1 incoming and 1 outgoing edge).
|
|
1956
|
+
* **Subproblem**: Evaluates the candidate tour graph for disconnected components. If isolated subtours exist, it lazily injects a Benders feasibility cut ($\sum_{i,j \in S} x_{ij} \le |S| - 1$) cutting off that cycle.
|
|
1957
|
+
|
|
1958
|
+
```typescript
|
|
1959
|
+
import { Model, expr, minimize, LinExpr, type Var } from "@ohtaman/washi";
|
|
1960
|
+
|
|
1961
|
+
const n = cities.length;
|
|
1962
|
+
const master = new Model("TSP_Master");
|
|
1963
|
+
const x: Record<string, Var> = {};
|
|
1964
|
+
const objExpr = new LinExpr();
|
|
1965
|
+
|
|
1966
|
+
for (let i = 0; i < n; i++) {
|
|
1967
|
+
for (let j = 0; j < n; j++) {
|
|
1968
|
+
if (i !== j) {
|
|
1969
|
+
const v = master.binary(`x_${i}_${j}`);
|
|
1970
|
+
x[`${i}_${j}`] = v;
|
|
1971
|
+
objExpr.addTerm(v, dist(i, j));
|
|
1972
|
+
}
|
|
1973
|
+
}
|
|
1974
|
+
}
|
|
1975
|
+
master.objective(minimize(objExpr));
|
|
1976
|
+
|
|
1977
|
+
// Degree constraints
|
|
1978
|
+
for (let i = 0; i < n; i++) {
|
|
1979
|
+
const outE = new LinExpr();
|
|
1980
|
+
const inE = new LinExpr();
|
|
1981
|
+
for (let j = 0; j < n; j++) {
|
|
1982
|
+
if (i !== j) {
|
|
1983
|
+
outE.addTerm(x[`${i}_${j}`], 1);
|
|
1984
|
+
inE.addTerm(x[`${j}_${i}`], 1);
|
|
1985
|
+
}
|
|
1986
|
+
}
|
|
1987
|
+
master.constraint(expr`${outE} == 1`, `out_${i}`);
|
|
1988
|
+
master.constraint(expr`${inE} == 1`, `in_${i}`);
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
// Solve with Benders lazy subtour elimination
|
|
1992
|
+
const result = await master.solveBenders({
|
|
1993
|
+
subproblem: () => {
|
|
1994
|
+
const cycles = findCycles(n, (i, j) => (x[`${i}_${j}`].value ?? 0) > 0.5);
|
|
1995
|
+
if (cycles.length === 1 && cycles[0].length === n) {
|
|
1996
|
+
return null; // Single tour covers all cities
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1999
|
+
// Subtour detected: inject Benders feasibility cut for smallest cycle
|
|
2000
|
+
const S = cycles.reduce((min, c) => (c.length < min.length ? c : min), cycles[0]);
|
|
2001
|
+
const secExpr = new LinExpr();
|
|
2002
|
+
for (const i of S) {
|
|
2003
|
+
for (const j of S) {
|
|
2004
|
+
if (i !== j) secExpr.addTerm(x[`${i}_${j}`], 1);
|
|
2005
|
+
}
|
|
2006
|
+
}
|
|
2007
|
+
return {
|
|
2008
|
+
kind: "feasibility",
|
|
2009
|
+
constraint: expr`${secExpr} <= ${S.length - 1}`,
|
|
2010
|
+
};
|
|
2011
|
+
},
|
|
2012
|
+
});
|
|
2013
|
+
```
|
|
2014
|
+
|
|
2015
|
+
---
|
|
2016
|
+
|
|
2017
|
+
# Solvers Overview
|
|
2018
|
+
|
|
2019
|
+
WaSHI is designed around a pluggable, decoupled solver architecture. You formulate mathematical models using a unified algebraic API, then solve them with the engine best suited for your problem class.
|
|
2020
|
+
|
|
2021
|
+
---
|
|
2022
|
+
|
|
2023
|
+
## ⚡ Solvers Matrix & Comparison
|
|
2024
|
+
|
|
2025
|
+
| Solver | Engine License | Problem Classes | Best For | Status in WaSHI |
|
|
2026
|
+
| :--- | :--- | :--- | :--- | :--- |
|
|
2027
|
+
| **[HiGHS](https://github.com/ERGO-Code/HiGHS)** | **MIT** | **LP, MIP, Convex QP** | Industrial-scale optimization, operations research, finance | **Built-in default** (zero configuration) |
|
|
2028
|
+
| **[Z3](https://github.com/Z3Prover/z3)** (via [`z3-solver`](https://www.npmjs.com/package/z3-solver)) | **MIT** | **SMT, SAT, Logic, MIP** | Propositional logic, conflict diagnosis (`unsatCore`), rule engines | Optional plugin (`@ohtaman/washi/solvers/z3`) |
|
|
2029
|
+
| **[GLPK](https://www.gnu.org/software/glpk/)** (via [`glpk.js`](https://www.npmjs.com/package/glpk.js)) | **GPL-3.0** | **LP, MIP** | Academic benchmarks, legacy GLPK compatibility | Optional plugin (`@ohtaman/washi/solvers/glpk`) |
|
|
2030
|
+
| **Mock** | **MIT** | *Any* | Ultra-fast unit testing in CI without loading WebAssembly | Built-in |
|
|
2031
|
+
|
|
2032
|
+
---
|
|
2033
|
+
|
|
2034
|
+
## Selecting a Solver
|
|
2035
|
+
|
|
2036
|
+
Specify the solver by name when creating your `Model`:
|
|
2037
|
+
|
|
2038
|
+
```typescript
|
|
2039
|
+
// 1. Built-in HiGHS (default)
|
|
2040
|
+
const m1 = new Model({ solver: "highs" });
|
|
2041
|
+
|
|
2042
|
+
// 2. Z3 SMT solver
|
|
2043
|
+
import "@ohtaman/washi/solvers/z3";
|
|
2044
|
+
const m2 = new Model({ solver: "z3" });
|
|
2045
|
+
|
|
2046
|
+
// 3. GLPK solver
|
|
2047
|
+
import "@ohtaman/washi/solvers/glpk";
|
|
2048
|
+
const m3 = new Model({ solver: "glpk" });
|
|
2049
|
+
```
|
|
2050
|
+
|
|
2051
|
+
---
|
|
2052
|
+
|
|
2053
|
+
## Solver Guides
|
|
2054
|
+
|
|
2055
|
+
- [HiGHS Guide](docs/solvers/highs.md): Web Worker multithreading, MIP tolerances, and QP solving.
|
|
2056
|
+
- [Z3 Guide](docs/solvers/z3.md): SMT assertions, logic modeling, and Unsat Core analysis.
|
|
2057
|
+
- [GLPK Guide](docs/solvers/glpk.md): GLPK adapter, GPL-3.0 licensing considerations, and browser usage.
|
|
2058
|
+
- [Custom Solvers](docs/solvers/custom-solver.md): Step-by-step developer tutorial for integrating proprietary or cloud REST solvers.
|
|
2059
|
+
|
|
2060
|
+
---
|
|
2061
|
+
|
|
2062
|
+
# HiGHS Solver Engine
|
|
2063
|
+
|
|
2064
|
+
**HiGHS** is high-performance mathematical optimization software developed at the University of Edinburgh ([ERGO-Code/HiGHS](https://github.com/ERGO-Code/HiGHS)). It is widely recognized as the state-of-the-art open-source solver for Linear Programming (LP), Mixed-Integer Programming (MIP), and Convex Quadratic Programming (QP).
|
|
2065
|
+
|
|
2066
|
+
In `@ohtaman/washi`, HiGHS runs entirely in WebAssembly compiled directly from official upstream source ([ERGO-Code/HiGHS](https://github.com/ERGO-Code/HiGHS)) using its native C API (`highs_c_api.h`), providing zero-config execution across Node.js, Web Workers, and browsers without third-party wrapper dependencies.
|
|
2067
|
+
|
|
2068
|
+
---
|
|
2069
|
+
|
|
2070
|
+
## Key Features in WaSHI
|
|
2071
|
+
|
|
2072
|
+
- **Built-in & Zero-Config**: Bundled directly with `@ohtaman/washi` with zero external runtime dependencies. No separate peer dependency or C++ compiler required.
|
|
2073
|
+
- **Permissive MIT License**: 100% commercially friendly.
|
|
2074
|
+
- **Web Worker Multithreading**: Automatically offloads intensive solving to background Web Workers in browser environments to avoid freezing the UI thread.
|
|
2075
|
+
- **Warm-Start Support (`initialValue`)**: Supports passing variable primal initial value hints (`initialValue`) to HiGHS `setSolution` for warm starting.
|
|
2076
|
+
- **Fast WASM Execution**: Runs the directly compiled 32-bit WebAssembly binary at near-native speed.
|
|
2077
|
+
|
|
2078
|
+
---
|
|
2079
|
+
|
|
2080
|
+
## Architecture & WebAssembly Runtime
|
|
2081
|
+
|
|
2082
|
+
WaSHI compiles official HiGHS C++ source directly to WebAssembly (`highs.wasm`) with `-fwasm-exceptions` and single-threaded universal portability (`-DHIGHS_NO_DEFAULT_THREADS=ON`). By interfacing directly with HiGHS's native C API (`highs_c_api.h`), WaSHI achieves zero-copy solution extraction via typed array views on WebAssembly linear memory, rapid upstream release tracking, and a self-contained zero-dependency distribution.
|
|
2083
|
+
|
|
2084
|
+
### Callbacks & Solver Interactivity
|
|
2085
|
+
|
|
2086
|
+
- **Progress Monitoring & Cancellation (Supported)**:
|
|
2087
|
+
Through HiGHS's internal logging and interrupt hooks, WaSHI provides real-time progress callbacks (`onProgress`) and cooperative mid-solve cancellation via standard `AbortSignal`.
|
|
2088
|
+
- **Dynamic Cuts / Lazy Constraints (Not Supported in WASM Wrapper)**:
|
|
2089
|
+
The upstream WebAssembly wrapper intentionally excludes low-level native callback type 8 (`MIP lazy constraints`) due to WASM-to-JS boundary and synchronous memory safety constraints. For problems requiring subtour elimination or Benders decomposition, use an **iterative cutting-plane loop** (solve $\to$ detect violation $\to$ `model.constraint(...)` $\to$ re-solve).
|
|
2090
|
+
|
|
2091
|
+
### WebAssembly Asset Lifecycle & On-Demand Builds
|
|
2092
|
+
|
|
2093
|
+
HiGHS's WebAssembly binary (`public/highs.wasm`) and runtime driver (`runtime.js`) are managed via `highs.config.json` with cryptographic SHA-256 verification:
|
|
2094
|
+
- **Zero-Friction Verification**: Build and distribution pipelines run `node scripts/ensure-highs-wasm.mjs`, verifying local assets in milliseconds before bundling.
|
|
2095
|
+
- **On-Demand Release Download**: When assets are missing or updated, `ensure-highs-wasm.mjs` automatically downloads verified binaries from official GitHub Release assets.
|
|
2096
|
+
- **Compiling from Source**: When updating HiGHS or modifying compiler flags, run `npm run build:highs-wasm` with the Emscripten SDK (`emcc`), CMake, and Ninja installed, and upload the rebuilt assets to the `highs-<version>` GitHub Release tag (`gh release upload highs-v1.15.1 public/highs.wasm src/modeler/solvers/highs/runtime.js --clobber`) so on-demand downloads match `highs.config.json` (or run `.github/workflows/build-wasm.yml` and commit the `highs-wasm-assets` artifact including `highs.config.json`).
|
|
2097
|
+
|
|
2098
|
+
---
|
|
2099
|
+
|
|
2100
|
+
## Supported Problem Classes
|
|
2101
|
+
|
|
2102
|
+
### 1. Linear Programming (LP)
|
|
2103
|
+
Solves continuous problems via Primal and Dual Simplex algorithms or Interior Point (IPM) methods. After solving, you can inspect constraint shadow prices (`c.dual`), slacks (`c.slack`), and variable reduced costs (`v.reducedCost`):
|
|
2104
|
+
|
|
2105
|
+
```typescript
|
|
2106
|
+
import { Model, expr, maximize } from "@ohtaman/washi";
|
|
2107
|
+
|
|
2108
|
+
const model = new Model({ solver: "highs" });
|
|
2109
|
+
const x = model.continuous("x", { lb: 0 });
|
|
2110
|
+
const y = model.continuous("y", { lb: 0 });
|
|
2111
|
+
model.objective(maximize(expr`10 * ${x} + 6 * ${y}`));
|
|
2112
|
+
model.constraint(expr`${x} + ${y} <= 100`, "cap");
|
|
2113
|
+
await model.solve();
|
|
2114
|
+
```
|
|
2115
|
+
|
|
2116
|
+
### 2. Mixed-Integer Linear Programming (MIP)
|
|
2117
|
+
Solves discrete problems using Branch-and-Bound and cutting plane techniques:
|
|
2118
|
+
|
|
2119
|
+
```typescript
|
|
2120
|
+
const count = model.integer("count", { lb: 0, ub: 50 });
|
|
2121
|
+
```
|
|
2122
|
+
|
|
2123
|
+
### 3. Convex Quadratic Programming (QP)
|
|
2124
|
+
Solves quadratic objective functions subject to linear constraints:
|
|
2125
|
+
|
|
2126
|
+
```typescript
|
|
2127
|
+
// Minimize risk/variance uniformly using expr:
|
|
2128
|
+
model.objective(minimize(expr`0.5 * ${x}^2 + 0.5 * ${y}^2`));
|
|
2129
|
+
|
|
2130
|
+
// Or programmatically with .mul():
|
|
2131
|
+
// const quadObj = x.mul(x).mul(0.5).add(y.mul(y).mul(0.5));
|
|
2132
|
+
// model.objective(minimize(quadObj));
|
|
2133
|
+
```
|
|
2134
|
+
|
|
2135
|
+
---
|
|
2136
|
+
|
|
2137
|
+
## Solver Options
|
|
2138
|
+
|
|
2139
|
+
Pass solver options into `model.solve()`:
|
|
2140
|
+
|
|
2141
|
+
```typescript
|
|
2142
|
+
const controller = new AbortController();
|
|
2143
|
+
|
|
2144
|
+
await model.solve({
|
|
2145
|
+
timeLimit: 60.0, // Maximum solve time in seconds
|
|
2146
|
+
mipGap: 0.001, // Terminate when within 0.1% of global optimality (alias: relGap)
|
|
2147
|
+
absGap: 1.0, // Absolute MIP optimality gap tolerance
|
|
2148
|
+
presolve: "on", // Presolve control ("on" | "off" | "choose")
|
|
2149
|
+
outputFlag: true, // Output internal simplex / branch-and-bound logs (output_flag)
|
|
2150
|
+
signal: controller.signal,
|
|
2151
|
+
onProgress: (ev) => {
|
|
2152
|
+
console.log(`Elapsed: ${ev.elapsedTime.toFixed(1)}s, Best: ${ev.objectiveValue}`);
|
|
2153
|
+
},
|
|
2154
|
+
});
|
|
2155
|
+
```
|
|
2156
|
+
|
|
2157
|
+
---
|
|
2158
|
+
|
|
2159
|
+
## Worker & WASM Configuration in Browsers
|
|
2160
|
+
|
|
2161
|
+
By default in browsers, WaSHI spawns a dedicated Web Worker to execute HiGHS in the background so UI rendering stays responsive and `onProgress` / `AbortSignal` work seamlessly.
|
|
2162
|
+
|
|
2163
|
+
If you need to run synchronously on the main thread (or in environments where Web Workers are prohibited), pass `useWorker: false` to `model.solve()` or instantiate `HighsSolver`:
|
|
2164
|
+
|
|
2165
|
+
```typescript
|
|
2166
|
+
import { Model, HighsSolver } from "@ohtaman/washi";
|
|
2167
|
+
|
|
2168
|
+
// Option 1: Per solve call
|
|
2169
|
+
await model.solve({ useWorker: false });
|
|
2170
|
+
|
|
2171
|
+
// Option 2: When instantiating the solver
|
|
2172
|
+
const syncModel = new Model({
|
|
2173
|
+
solver: new HighsSolver({ useWorker: false }),
|
|
2174
|
+
});
|
|
2175
|
+
```
|
|
2176
|
+
|
|
2177
|
+
---
|
|
2178
|
+
|
|
2179
|
+
# Z3 SMT Solver Engine
|
|
2180
|
+
|
|
2181
|
+
**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.
|
|
2182
|
+
|
|
2183
|
+
---
|
|
2184
|
+
|
|
2185
|
+
## Installation
|
|
2186
|
+
|
|
2187
|
+
Z3 is provided as an optional peer dependency:
|
|
2188
|
+
|
|
2189
|
+
```bash
|
|
2190
|
+
npm install z3-solver
|
|
2191
|
+
```
|
|
2192
|
+
|
|
2193
|
+
---
|
|
2194
|
+
|
|
2195
|
+
## When to Use Z3?
|
|
2196
|
+
|
|
2197
|
+
1. **Unsat Core Conflict Diagnosis**:
|
|
2198
|
+
When your MIP model is infeasible and you need to isolate which constraints are contradictory.
|
|
2199
|
+
2. **Boolean / Logical Formulations**:
|
|
2200
|
+
When solving problems containing complex logical relations (implications, XOR, nested Boolean expressions).
|
|
2201
|
+
3. **Exact Mathematical Guarantees**:
|
|
2202
|
+
Z3 can perform exact rational arithmetic without floating-point inaccuracies.
|
|
2203
|
+
|
|
2204
|
+
---
|
|
2205
|
+
|
|
2206
|
+
## Basic Usage
|
|
2207
|
+
|
|
2208
|
+
```typescript
|
|
2209
|
+
import { Model, expr } from "@ohtaman/washi";
|
|
2210
|
+
import "@ohtaman/washi/solvers/z3";
|
|
2211
|
+
|
|
2212
|
+
const model = new Model({ solver: "z3" });
|
|
2213
|
+
|
|
2214
|
+
const x = model.integer("x", { lb: 0, ub: 10 });
|
|
2215
|
+
const y = model.integer("y", { lb: 0, ub: 10 });
|
|
2216
|
+
|
|
2217
|
+
model.constraint(expr`${x} + ${y} == 15`, "sum_15");
|
|
2218
|
+
model.constraint(expr`${x} >= 8`, "x_at_least_8");
|
|
2219
|
+
|
|
2220
|
+
await model.solve();
|
|
2221
|
+
console.log(`x = ${x.value}, y = ${y.value}`);
|
|
2222
|
+
```
|
|
2223
|
+
|
|
2224
|
+
---
|
|
2225
|
+
|
|
2226
|
+
## Unsat Core Extraction (`model.check()`)
|
|
2227
|
+
|
|
2228
|
+
Use `await model.check()` with Z3 to verify satisfiability and extract the conflicting subset of named constraints (`conflicts` / `unsatCore`) when a model is unsatisfiable:
|
|
2229
|
+
|
|
2230
|
+
```typescript
|
|
2231
|
+
const model = new Model({ solver: "z3" });
|
|
2232
|
+
const a = model.continuous("a");
|
|
2233
|
+
|
|
2234
|
+
model.constraint(expr`${a} >= 10`, "req_A");
|
|
2235
|
+
model.constraint(expr`${a} <= 5`, "req_B");
|
|
2236
|
+
|
|
2237
|
+
const result = await model.check();
|
|
2238
|
+
|
|
2239
|
+
if (result.status === "Unsat") {
|
|
2240
|
+
console.log("Conflicting constraints:", result.conflicts); // or model.conflicts / model.unsatCore
|
|
2241
|
+
// ["req_A", "req_B"]
|
|
2242
|
+
}
|
|
2243
|
+
```
|
|
2244
|
+
|
|
2245
|
+
---
|
|
2246
|
+
|
|
2247
|
+
## Browser Security Note
|
|
2248
|
+
|
|
2249
|
+
In browser environments, the Z3 WASM engine relies on pthreads and `SharedArrayBuffer`, which require cross-origin isolation headers (`COOP` / `COEP`). See the [Cross-Origin Isolation Guide](docs/getting-started/cross-origin.md) for detailed setup.
|
|
2250
|
+
|
|
2251
|
+
## Runtime Failures and Thread Cleanup
|
|
2252
|
+
|
|
2253
|
+
If a Z3 worker encounters a fatal runtime error, pending operations on that instance reject with an error and the broken runtime is invalidated; subsequent `solve()` or `check()` calls automatically initialize a fresh Z3 runtime rather than misclassifying failures as `Sat` or `Unsat`.
|
|
2254
|
+
|
|
2255
|
+
To explicitly terminate Z3 pthread workers when tearing down a browser view or worker pool, call `killZ3Threads()` from `@ohtaman/washi/solvers/z3`.
|
|
2256
|
+
|
|
2257
|
+
---
|
|
2258
|
+
|
|
2259
|
+
# GLPK Solver Engine
|
|
2260
|
+
|
|
2261
|
+
The **GNU Linear Programming Kit (GLPK)** is a long-standing, robust simplex and interior-point solver package intended for solving large-scale linear programming (LP) and mixed integer programming (MIP).
|
|
2262
|
+
|
|
2263
|
+
---
|
|
2264
|
+
|
|
2265
|
+
## Installation
|
|
2266
|
+
|
|
2267
|
+
GLPK is provided as an optional peer dependency via `glpk.js`:
|
|
2268
|
+
|
|
2269
|
+
```bash
|
|
2270
|
+
npm install glpk.js
|
|
2271
|
+
```
|
|
2272
|
+
|
|
2273
|
+
---
|
|
2274
|
+
|
|
2275
|
+
## Licensing Consideration (GPL-3.0)
|
|
2276
|
+
|
|
2277
|
+
GLPK is licensed under the **GNU General Public License v3.0 (GPL-3.0)**.
|
|
2278
|
+
|
|
2279
|
+
- Because WaSHI is MIT-licensed, `glpk.js` is **never bundled statically** in the core package.
|
|
2280
|
+
- It is dynamically imported only if you explicitly register or invoke `@ohtaman/washi/solvers/glpk`.
|
|
2281
|
+
- Ensure your project's distribution requirements comply with the GPL-3.0 license when shipping products that bundle GLPK.
|
|
2282
|
+
|
|
2283
|
+
---
|
|
2284
|
+
|
|
2285
|
+
## Basic Usage
|
|
2286
|
+
|
|
2287
|
+
```typescript
|
|
2288
|
+
import { Model, expr, maximize } from "@ohtaman/washi";
|
|
2289
|
+
import "@ohtaman/washi/solvers/glpk";
|
|
2290
|
+
|
|
2291
|
+
const model = new Model({ solver: "glpk" });
|
|
2292
|
+
|
|
2293
|
+
const x = model.continuous("x", { lb: 0, ub: 10 });
|
|
2294
|
+
const y = model.continuous("y", { lb: 0, ub: 20 });
|
|
2295
|
+
|
|
2296
|
+
model.objective(maximize(expr`3 * ${x} + 2 * ${y}`));
|
|
2297
|
+
model.constraint(expr`${x} + ${y} <= 15`, "cap");
|
|
2298
|
+
|
|
2299
|
+
await model.solve();
|
|
2300
|
+
|
|
2301
|
+
console.log("GLPK Status:", model.status);
|
|
2302
|
+
console.log("Objective:", model.objectiveValue);
|
|
2303
|
+
```
|
|
2304
|
+
|
|
2305
|
+
---
|
|
2306
|
+
|
|
2307
|
+
## Result Semantics & Status Mapping
|
|
2308
|
+
|
|
2309
|
+
### Solver Statuses
|
|
2310
|
+
GLPK return codes are mapped strictly to WaSHI `SolverStatus`:
|
|
2311
|
+
- `GLP_OPT` (5) $\to$ `"Optimal"`
|
|
2312
|
+
- `GLP_FEAS` (2) $\to$ `"Feasible"` (feasible MIP incumbent found)
|
|
2313
|
+
- `GLP_INFEAS` (3) / `GLP_NOFEAS` (4) $\to$ `"Infeasible"`
|
|
2314
|
+
- `GLP_UNBND` (6) $\to$ `"Unbounded"`
|
|
2315
|
+
- `GLP_UNDEF` (1) and unrecognized/missing statuses $\to$ `"Unknown"` (never misclassified as `"Optimal"`)
|
|
2316
|
+
|
|
2317
|
+
### Solution Availability
|
|
2318
|
+
- Variable values (`model.values`, `var.value`) and objective values (`model.objectiveValue`) are exposed **only** when a valid primal solution is available (`"Optimal"` or `"Feasible"`).
|
|
2319
|
+
- For unsuccessful or non-optimal solves (`Infeasible`, `Unbounded`, `Unknown`, `Error`), `model.objectiveValue` is `null` and variable values remain `null` without fabricating fake all-zero solutions.
|
|
2320
|
+
|
|
2321
|
+
### Differences Between LP and MIP
|
|
2322
|
+
- **Linear Programming (LP)**: Solved using the simplex algorithm. Dual values (shadow prices via `constraint.dual`) and slacks are populated for optimal solutions.
|
|
2323
|
+
- **Mixed-Integer Programming (MIP)**: Solved using the branch-and-cut algorithm. Produces integer-feasible primal variable solutions; dual values are mathematically undefined for discrete optimization and omitted.
|
|
2324
|
+
|
|
2325
|
+
---
|
|
2326
|
+
|
|
2327
|
+
# WaSHI Custom Solver Developer Guide
|
|
2328
|
+
|
|
2329
|
+
This guide describes how to build, test, and distribute custom solver plugins for **WaSHI** (`@ohtaman/washi`).
|
|
2330
|
+
|
|
2331
|
+
WaSHI features a pluggable, decoupled solver architecture. Core `@ohtaman/washi` bundles only the zero-config HiGHS WASM engine and a Mock solver. Any third-party solver—whether implemented in WebAssembly, native C++, a Web Worker, or an external cloud REST/RPC backend—can be registered and used through the unified `Model` API.
|
|
2332
|
+
|
|
2333
|
+
---
|
|
2334
|
+
|
|
2335
|
+
## 1. Pluggable Solvers Architecture Overview
|
|
2336
|
+
|
|
2337
|
+
```mermaid
|
|
2338
|
+
graph TD
|
|
2339
|
+
A[WaSHI Model] --> B[Solver Registry Map Symbol.for]
|
|
2340
|
+
B --> C[HiGHS WASM Engine Core]
|
|
2341
|
+
B --> D[Mock Solver Core]
|
|
2342
|
+
B --> E[GLPK Plugin @ohtaman/washi/solvers/glpk]
|
|
2343
|
+
B --> F[Z3 SMT Plugin @ohtaman/washi/solvers/z3]
|
|
2344
|
+
B --> G[Your Custom Solver Plugin]
|
|
2345
|
+
```
|
|
2346
|
+
|
|
2347
|
+
### Key Architectural Characteristics
|
|
2348
|
+
1. **Unified Global Registry**: The solver registry is backed by `Symbol.for("washi.solverRegistry")` on `globalThis`. This ensures that even across different ESM chunks, dynamic imports, or `<script>` tags, every bundle accesses the identical solver map.
|
|
2349
|
+
2. **Behavioral Symmetry**: Node.js and browser environments share identical error messages, solver naming, and capability handling.
|
|
2350
|
+
3. **Decoupled Bundling**: Core WaSHI does not import optional or custom plugins. A plugin only registers itself when explicitly imported.
|
|
2351
|
+
|
|
2352
|
+
---
|
|
2353
|
+
|
|
2354
|
+
## 2. API Contracts & TypeScript Interfaces
|
|
2355
|
+
|
|
2356
|
+
All custom solvers must implement the `Solver` interface defined in `@ohtaman/washi`:
|
|
2357
|
+
|
|
2358
|
+
```typescript
|
|
2359
|
+
import {
|
|
2360
|
+
generateLpString,
|
|
2361
|
+
buildLpSymbolMaps,
|
|
2362
|
+
isLpExportResult,
|
|
2363
|
+
sanitizeIdentifier,
|
|
2364
|
+
type Solver,
|
|
2365
|
+
type SolverOptions,
|
|
2366
|
+
type SolverResult,
|
|
2367
|
+
type SolverCheckResult,
|
|
2368
|
+
type SolverCapabilities,
|
|
2369
|
+
type SolveModelData,
|
|
2370
|
+
type CheckModelData,
|
|
2371
|
+
type LogicalConstraintItem,
|
|
2372
|
+
type LpModelData,
|
|
2373
|
+
type LpExportResult,
|
|
2374
|
+
type LpSymbolMaps,
|
|
2375
|
+
type SolverStatus,
|
|
2376
|
+
type TerminationStatus,
|
|
2377
|
+
type SolveProgressEvent,
|
|
2378
|
+
} from "@ohtaman/washi";
|
|
2379
|
+
```
|
|
2380
|
+
|
|
2381
|
+
### The `Solver` Interface
|
|
2382
|
+
|
|
2383
|
+
```typescript
|
|
2384
|
+
export interface Solver {
|
|
2385
|
+
/** Unique human-readable name of the solver (e.g. "GLPK", "AcmeCloudSolver") */
|
|
2386
|
+
readonly name: string;
|
|
2387
|
+
|
|
2388
|
+
/** Solver version string (e.g. "1.2.0") */
|
|
2389
|
+
readonly version?: string;
|
|
2390
|
+
|
|
2391
|
+
/** Capability flags declaring which problem classes the solver supports */
|
|
2392
|
+
readonly capabilities?: SolverCapabilities;
|
|
2393
|
+
|
|
2394
|
+
/**
|
|
2395
|
+
* Main solve method for LP, MIP, QP, and SMT optimization models.
|
|
2396
|
+
*
|
|
2397
|
+
* @param modelData Typed in-memory model structure (variables, constraints, objective, sense, boolVars, logicalConstraints)
|
|
2398
|
+
* @param options Runtime execution options (timeLimit, mipGap, verbose, signal, onProgress, etc.)
|
|
2399
|
+
*/
|
|
2400
|
+
solve(
|
|
2401
|
+
modelData: SolveModelData,
|
|
2402
|
+
options?: SolverOptions
|
|
2403
|
+
): Promise<SolverResult>;
|
|
2404
|
+
|
|
2405
|
+
/**
|
|
2406
|
+
* Optional check method for Pure SAT / SMT or feasibility checking without an objective.
|
|
2407
|
+
*
|
|
2408
|
+
* @param modelData Objective-free in-memory model structure (variables, boolVars, constraints, logicalConstraints)
|
|
2409
|
+
* @param options Runtime execution options
|
|
2410
|
+
*/
|
|
2411
|
+
check?(
|
|
2412
|
+
modelData: CheckModelData,
|
|
2413
|
+
options?: SolverOptions
|
|
2414
|
+
): Promise<SolverCheckResult>;
|
|
2415
|
+
}
|
|
2416
|
+
```
|
|
2417
|
+
|
|
2418
|
+
### `CheckModelData` & `SolveModelData`
|
|
2419
|
+
|
|
2420
|
+
`Model.solve()` and `Model.check()` pass the structured model directly to your adapter without serializing an LP string upfront:
|
|
2421
|
+
|
|
2422
|
+
```typescript
|
|
2423
|
+
export interface CheckModelData {
|
|
2424
|
+
/** Model name */
|
|
2425
|
+
name?: string;
|
|
2426
|
+
/** Decision variables registered in the model */
|
|
2427
|
+
variables: Var[];
|
|
2428
|
+
/** Propositional Boolean variables registered in the model */
|
|
2429
|
+
boolVars?: BoolVar[];
|
|
2430
|
+
/** Linear arithmetic constraints registered in the model */
|
|
2431
|
+
constraints: Constraint[];
|
|
2432
|
+
/** Logical / SMT constraints registered in the model */
|
|
2433
|
+
logicalConstraints?: LogicalConstraintItem[];
|
|
2434
|
+
}
|
|
2435
|
+
|
|
2436
|
+
export interface SolveModelData extends CheckModelData {
|
|
2437
|
+
/** Optimization direction ("Minimize" | "Maximize") */
|
|
2438
|
+
sense: OptimizationSense;
|
|
2439
|
+
/** Linear or quadratic objective expression */
|
|
2440
|
+
objective: LinExpr | QuadExpr;
|
|
2441
|
+
}
|
|
2442
|
+
```
|
|
2443
|
+
|
|
2444
|
+
### `SolverCapabilities`
|
|
2445
|
+
|
|
2446
|
+
Custom solvers declare their supported features using `capabilities`. Before dispatching `solve()` or `check()`, `Model` validates:
|
|
2447
|
+
- `supportsQP === false` when the objective contains quadratic terms (`QuadExpr`),
|
|
2448
|
+
- when the model contains logical constraints (`m.logicalConstraints`) and the solver does not support SMT (`supportsSMT !== true`), `Model` automatically linearizes them into equivalent 0-1 MIP binary variables and linear constraints via `linearizeModelForMip`, and
|
|
2449
|
+
- `supportsWarmStart !== true` when any variable has a primal `initialValue` set.
|
|
2450
|
+
|
|
2451
|
+
```typescript
|
|
2452
|
+
export interface SolverCapabilities {
|
|
2453
|
+
/** Supports mixed-integer programming (integer/binary variables) */
|
|
2454
|
+
supportsMIP?: boolean;
|
|
2455
|
+
|
|
2456
|
+
/** Supports quadratic objectives (QP: x^T Q x + c^T x) */
|
|
2457
|
+
supportsQP?: boolean;
|
|
2458
|
+
|
|
2459
|
+
/** Supports second-order cone or quadratic constraints */
|
|
2460
|
+
supportsQCQP?: boolean;
|
|
2461
|
+
|
|
2462
|
+
/** Supports satisfiability modulo theories / boolean logic constraints */
|
|
2463
|
+
supportsSMT?: boolean;
|
|
2464
|
+
|
|
2465
|
+
/** Supports extracting an unsatisfiable core (conflicting constraint subset) */
|
|
2466
|
+
supportsUnsatCore?: boolean;
|
|
2467
|
+
|
|
2468
|
+
/** Supports primal initial values / MIP warm starts (Var.initialValue) */
|
|
2469
|
+
supportsWarmStart?: boolean;
|
|
2470
|
+
}
|
|
2471
|
+
```
|
|
2472
|
+
|
|
2473
|
+
### `SolverOptions` & `SolveProgressEvent`
|
|
2474
|
+
|
|
2475
|
+
Runtime options passed to `solve` and `check` include cancellation and progress hooks (note that the routing `solver` field from `SolveOptions` is stripped before options are passed to your adapter):
|
|
2476
|
+
|
|
2477
|
+
```typescript
|
|
2478
|
+
export interface SolverOptions {
|
|
2479
|
+
/** Time limit in seconds */
|
|
2480
|
+
timeLimit?: number;
|
|
2481
|
+
|
|
2482
|
+
/** Relative MIP optimality gap tolerance */
|
|
2483
|
+
mipGap?: number;
|
|
2484
|
+
|
|
2485
|
+
/** Enable solver console logging */
|
|
2486
|
+
verbose?: boolean;
|
|
2487
|
+
|
|
2488
|
+
/** Cooperative cancellation signal; when aborted, return terminationStatus: "Interrupted" */
|
|
2489
|
+
signal?: AbortSignal;
|
|
2490
|
+
|
|
2491
|
+
/** Progress callback invoked with normalized SolveProgressEvent snapshots */
|
|
2492
|
+
onProgress?: (progress: SolveProgressEvent) => void;
|
|
2493
|
+
|
|
2494
|
+
/** Additional solver-specific options */
|
|
2495
|
+
[key: string]: any;
|
|
2496
|
+
}
|
|
2497
|
+
```
|
|
2498
|
+
|
|
2499
|
+
### Optional LP Serialization & Symbol-Mapping Helpers
|
|
2500
|
+
|
|
2501
|
+
Solvers that consume structured JS/WASM data (such as `GLPKSolver` and `Z3Solver`) work directly with `SolveModelData` / `CheckModelData`.
|
|
2502
|
+
If your adapter targets a backend that requires CPLEX/HiGHS LP text or restricted identifier syntax, WaSHI exports opt-in helpers:
|
|
2503
|
+
|
|
2504
|
+
- `generateLpString(modelData): LpExportResult` — serializes `modelData` into a CPLEX LP format string (`lpString`) along with collision-safe symbol lookup maps (`nameToVar`, `nameToConstraint`).
|
|
2505
|
+
- `buildLpSymbolMaps(modelData): LpSymbolMaps` — builds collision-safe sanitized symbol maps (`nameToVar`, `varToName`, `nameToConstraint`, `constraintToName`) without serializing an LP string.
|
|
2506
|
+
- `isLpExportResult(value): value is LpExportResult` — runtime type guard for `LpExportResult` (useful when supporting legacy 3-argument `solve(modelData, lpResult, options)` overloads).
|
|
2507
|
+
- `sanitizeIdentifier(raw, fallbackPrefix = "x", id = 0)` — sanitizes an arbitrary display name into a valid LP identifier.
|
|
2508
|
+
|
|
2509
|
+
```typescript
|
|
2510
|
+
export interface LpExportResult {
|
|
2511
|
+
/** Standard CPLEX/HiGHS LP formatted string */
|
|
2512
|
+
lpString: string;
|
|
2513
|
+
|
|
2514
|
+
/** Map translating sanitized LP variable identifiers back to WaSHI Var objects */
|
|
2515
|
+
nameToVar: Map<string, Var>;
|
|
2516
|
+
|
|
2517
|
+
/** Map translating sanitized LP constraint identifiers back to WaSHI Constraint objects */
|
|
2518
|
+
nameToConstraint: Map<string, Constraint>;
|
|
2519
|
+
}
|
|
2520
|
+
```
|
|
2521
|
+
|
|
2522
|
+
### `SolverResult` Normalization & Identity Decoding
|
|
2523
|
+
|
|
2524
|
+
The `solve` method must return normalized results keyed by the model's canonical variable/constraint names (`v.name`, `bv.name`, `c.name`). Adapters that use `generateLpString(modelData)` or `buildLpSymbolMaps(modelData)` for an LP backend must translate sanitized LP identifiers back to canonical model names using `nameToVar` and `nameToConstraint`:
|
|
2525
|
+
|
|
2526
|
+
```typescript
|
|
2527
|
+
export interface SolverResult {
|
|
2528
|
+
/** Standardized status string */
|
|
2529
|
+
status: SolverStatus;
|
|
2530
|
+
|
|
2531
|
+
/** Exact termination reason independent of whether a feasible incumbent is available (defaults to status) */
|
|
2532
|
+
terminationStatus?: TerminationStatus;
|
|
2533
|
+
|
|
2534
|
+
/** Whether a feasible primal solution / incumbent is available (inferred from status/values if omitted) */
|
|
2535
|
+
hasPrimalSolution?: boolean;
|
|
2536
|
+
|
|
2537
|
+
/** Optimal or best objective value (null if no primal solution is available) */
|
|
2538
|
+
objectiveValue: number | null;
|
|
2539
|
+
|
|
2540
|
+
/** Best proven dual/objective bound when provided by the solver */
|
|
2541
|
+
objectiveBound?: number;
|
|
2542
|
+
|
|
2543
|
+
/** Relative MIP optimality gap when provided by the solver */
|
|
2544
|
+
relGap?: number;
|
|
2545
|
+
|
|
2546
|
+
/** Alias for relGap: relative MIP optimality gap when provided by the solver */
|
|
2547
|
+
mipGap?: number;
|
|
2548
|
+
|
|
2549
|
+
/** Absolute MIP optimality gap |objectiveValue - objectiveBound| (computed automatically when both are finite) */
|
|
2550
|
+
absGap?: number;
|
|
2551
|
+
|
|
2552
|
+
/** Variable primal values keyed by canonical variable name (non-finite values are ignored) */
|
|
2553
|
+
values: Record<string, number>;
|
|
2554
|
+
|
|
2555
|
+
/** Propositional Boolean variable assignments keyed by BoolVar name (optional, SMT/SAT) */
|
|
2556
|
+
boolValues?: Record<string, boolean>;
|
|
2557
|
+
|
|
2558
|
+
/** Variable reduced costs (optional, LP only) */
|
|
2559
|
+
reducedCosts?: Record<string, number>;
|
|
2560
|
+
|
|
2561
|
+
/** Constraint dual values / shadow prices (optional, LP only) */
|
|
2562
|
+
duals?: Record<string, number>;
|
|
2563
|
+
|
|
2564
|
+
/** Constraint slack values (optional) */
|
|
2565
|
+
slacks?: Record<string, number>;
|
|
2566
|
+
|
|
2567
|
+
/** Solve execution duration in seconds */
|
|
2568
|
+
runTime?: number;
|
|
2569
|
+
|
|
2570
|
+
/** Diagnostic solver logs / text outputs */
|
|
2571
|
+
logs?: string[];
|
|
2572
|
+
|
|
2573
|
+
/** Raw solver response object for inspection */
|
|
2574
|
+
raw?: any;
|
|
2575
|
+
}
|
|
2576
|
+
```
|
|
2577
|
+
|
|
2578
|
+
#### Standardized `SolverStatus` Values
|
|
2579
|
+
- `"Optimal"`: Global optimal solution found (for MIP within tolerance, for LP proven optimal).
|
|
2580
|
+
- `"Feasible"`: Feasible solution found, but optimality not yet proven (e.g., hit time limit with an incumbent).
|
|
2581
|
+
- `"Infeasible"`: Model is proven mathematically infeasible.
|
|
2582
|
+
- `"Unbounded"`: Model objective is unbounded.
|
|
2583
|
+
- `"TimeLimit"`: Solver terminated due to `timeLimit` without proof of optimality.
|
|
2584
|
+
- `"IterationLimit"`: Solver terminated due to iteration limit.
|
|
2585
|
+
- `"Sat"` / `"Unsat"`: Satisfiable / unsatisfiable status returned by `check()` or SMT solvers.
|
|
2586
|
+
- `"Error"` / `"NotSolved"` / `"Unknown"`: Error, not yet solved, or unrecognized status. **Never convert Unknown/Error to Optimal.**
|
|
2587
|
+
|
|
2588
|
+
---
|
|
2589
|
+
|
|
2590
|
+
## 3. Step-by-Step Implementation Tutorials
|
|
2591
|
+
|
|
2592
|
+
### Tutorial A: In-House Cloud REST Solver Adapter
|
|
2593
|
+
|
|
2594
|
+
Here is a complete, production-ready custom solver connecting to an HTTP microservice that accepts LP format strings on demand via `generateLpString(modelData)`:
|
|
2595
|
+
|
|
2596
|
+
```typescript
|
|
2597
|
+
import {
|
|
2598
|
+
generateLpString,
|
|
2599
|
+
registerSolver,
|
|
2600
|
+
type Solver,
|
|
2601
|
+
type SolverOptions,
|
|
2602
|
+
type SolverResult,
|
|
2603
|
+
type SolverStatus,
|
|
2604
|
+
type SolveModelData,
|
|
2605
|
+
} from "@ohtaman/washi";
|
|
2606
|
+
|
|
2607
|
+
export interface CloudSolverConfig extends SolverOptions {
|
|
2608
|
+
endpoint?: string;
|
|
2609
|
+
apiKey?: string;
|
|
2610
|
+
}
|
|
2611
|
+
|
|
2612
|
+
export class CloudSolver implements Solver {
|
|
2613
|
+
public readonly name = "CloudSolver";
|
|
2614
|
+
public readonly version = "1.0.0";
|
|
2615
|
+
public readonly capabilities = {
|
|
2616
|
+
supportsMIP: true,
|
|
2617
|
+
supportsQP: false,
|
|
2618
|
+
};
|
|
2619
|
+
|
|
2620
|
+
private endpoint: string;
|
|
2621
|
+
private apiKey: string;
|
|
2622
|
+
|
|
2623
|
+
constructor(config: CloudSolverConfig = {}) {
|
|
2624
|
+
this.endpoint = config.endpoint || "https://solver.mycompany.internal/v1/solve";
|
|
2625
|
+
this.apiKey = config.apiKey || "";
|
|
2626
|
+
}
|
|
2627
|
+
|
|
2628
|
+
async solve(
|
|
2629
|
+
modelData: SolveModelData,
|
|
2630
|
+
options: SolverOptions = {}
|
|
2631
|
+
): Promise<SolverResult> {
|
|
2632
|
+
const startTime = performance.now();
|
|
2633
|
+
const { lpString, nameToVar, nameToConstraint } = generateLpString(modelData);
|
|
2634
|
+
|
|
2635
|
+
let payload: any;
|
|
2636
|
+
try {
|
|
2637
|
+
const response = await fetch(this.endpoint, {
|
|
2638
|
+
method: "POST",
|
|
2639
|
+
headers: {
|
|
2640
|
+
"Content-Type": "application/json",
|
|
2641
|
+
...(this.apiKey ? { Authorization: `Bearer ${this.apiKey}` } : {}),
|
|
2642
|
+
},
|
|
2643
|
+
body: JSON.stringify({
|
|
2644
|
+
lp: lpString,
|
|
2645
|
+
timeLimit: options.timeLimit,
|
|
2646
|
+
mipGap: options.mipGap,
|
|
2647
|
+
}),
|
|
2648
|
+
signal: options.signal,
|
|
2649
|
+
});
|
|
2650
|
+
|
|
2651
|
+
if (!response.ok) {
|
|
2652
|
+
throw new Error(`[CloudSolver] Server returned HTTP ${response.status}: ${await response.text()}`);
|
|
2653
|
+
}
|
|
2654
|
+
|
|
2655
|
+
payload = await response.json();
|
|
2656
|
+
} catch (err: any) {
|
|
2657
|
+
if (options.signal?.aborted || err?.name === "AbortError") {
|
|
2658
|
+
return {
|
|
2659
|
+
status: "Unknown",
|
|
2660
|
+
terminationStatus: "Interrupted",
|
|
2661
|
+
objectiveValue: null,
|
|
2662
|
+
values: {},
|
|
2663
|
+
runTime: (performance.now() - startTime) / 1000,
|
|
2664
|
+
};
|
|
2665
|
+
}
|
|
2666
|
+
throw err;
|
|
2667
|
+
}
|
|
2668
|
+
|
|
2669
|
+
const runTime = (performance.now() - startTime) / 1000;
|
|
2670
|
+
|
|
2671
|
+
const values: Record<string, number> = Object.create(null);
|
|
2672
|
+
for (const [k, val] of Object.entries((payload.values || {}) as Record<string, number>)) {
|
|
2673
|
+
values[nameToVar.get(k)?.name ?? k] = val;
|
|
2674
|
+
}
|
|
2675
|
+
const duals: Record<string, number> = Object.create(null);
|
|
2676
|
+
for (const [k, val] of Object.entries((payload.duals || {}) as Record<string, number>)) {
|
|
2677
|
+
duals[nameToConstraint.get(k)?.name ?? k] = val;
|
|
2678
|
+
}
|
|
2679
|
+
const slacks: Record<string, number> = Object.create(null);
|
|
2680
|
+
for (const [k, val] of Object.entries((payload.slacks || {}) as Record<string, number>)) {
|
|
2681
|
+
slacks[nameToConstraint.get(k)?.name ?? k] = val;
|
|
2682
|
+
}
|
|
2683
|
+
|
|
2684
|
+
const statusMap: Record<string, SolverStatus> = {
|
|
2685
|
+
OPTIMAL: "Optimal",
|
|
2686
|
+
FEASIBLE: "Feasible",
|
|
2687
|
+
INFEASIBLE: "Infeasible",
|
|
2688
|
+
UNBOUNDED: "Unbounded",
|
|
2689
|
+
TIME_LIMIT: "TimeLimit",
|
|
2690
|
+
TIMELIMIT: "TimeLimit",
|
|
2691
|
+
ITERATION_LIMIT: "IterationLimit",
|
|
2692
|
+
ERROR: "Error",
|
|
2693
|
+
NOT_SOLVED: "NotSolved",
|
|
2694
|
+
SAT: "Sat",
|
|
2695
|
+
UNSAT: "Unsat",
|
|
2696
|
+
};
|
|
2697
|
+
const rawStatus: SolverStatus = statusMap[String(payload.status).toUpperCase()] ?? "Unknown";
|
|
2698
|
+
const hasIncumbent =
|
|
2699
|
+
(typeof payload.objectiveValue === "number" && Number.isFinite(payload.objectiveValue)) ||
|
|
2700
|
+
Object.values(values).some((val) => typeof val === "number" && Number.isFinite(val));
|
|
2701
|
+
const isLimitStop = rawStatus === "TimeLimit" || rawStatus === "IterationLimit";
|
|
2702
|
+
const status: SolverStatus = isLimitStop && hasIncumbent ? "Feasible" : rawStatus;
|
|
2703
|
+
|
|
2704
|
+
return {
|
|
2705
|
+
status,
|
|
2706
|
+
terminationStatus: rawStatus,
|
|
2707
|
+
objectiveValue: payload.objectiveValue ?? null,
|
|
2708
|
+
values,
|
|
2709
|
+
duals,
|
|
2710
|
+
slacks,
|
|
2711
|
+
runTime: payload.solveDuration || runTime,
|
|
2712
|
+
logs: payload.logs || [],
|
|
2713
|
+
raw: payload,
|
|
2714
|
+
};
|
|
2715
|
+
}
|
|
2716
|
+
}
|
|
2717
|
+
|
|
2718
|
+
// Auto-register so `import "my-cloud-solver"` enables `solver: "cloud"`
|
|
2719
|
+
export function registerCloudSolver(): void {
|
|
2720
|
+
registerSolver("cloud", (opts) => new CloudSolver(opts));
|
|
2721
|
+
}
|
|
2722
|
+
registerCloudSolver();
|
|
2723
|
+
```
|
|
2724
|
+
|
|
2725
|
+
---
|
|
2726
|
+
|
|
2727
|
+
### Tutorial B: Registering and Using the Custom Solver
|
|
2728
|
+
|
|
2729
|
+
Once implemented, the custom solver can be selected in three different ways:
|
|
2730
|
+
|
|
2731
|
+
#### Option 1: By Registered Name String
|
|
2732
|
+
```typescript
|
|
2733
|
+
import { Model } from "@ohtaman/washi";
|
|
2734
|
+
import "./cloud-solver.js"; // Auto-registers "cloud"
|
|
2735
|
+
|
|
2736
|
+
const model = new Model({ name: "SupplyChain", solver: "cloud" });
|
|
2737
|
+
// Or model.solve("cloud")
|
|
2738
|
+
```
|
|
2739
|
+
|
|
2740
|
+
#### Option 2: By Passing the Solver Instance Directly
|
|
2741
|
+
```typescript
|
|
2742
|
+
import { Model } from "@ohtaman/washi";
|
|
2743
|
+
import { CloudSolver } from "./cloud-solver.js";
|
|
2744
|
+
|
|
2745
|
+
const customInstance = new CloudSolver({
|
|
2746
|
+
endpoint: "https://dedicated-gpu-cluster.internal/solve",
|
|
2747
|
+
apiKey: "secret-token",
|
|
2748
|
+
});
|
|
2749
|
+
|
|
2750
|
+
const model = new Model({ name: "FacilityLocation", solver: customInstance });
|
|
2751
|
+
const status = await model.solve();
|
|
2752
|
+
```
|
|
2753
|
+
|
|
2754
|
+
#### Option 3: Setting Global Default Solver
|
|
2755
|
+
```typescript
|
|
2756
|
+
import { setDefaultSolver, Model } from "@ohtaman/washi";
|
|
2757
|
+
import { CloudSolver } from "./cloud-solver.js";
|
|
2758
|
+
|
|
2759
|
+
setDefaultSolver(() => new CloudSolver());
|
|
2760
|
+
|
|
2761
|
+
// All subsequent models use CloudSolver by default
|
|
2762
|
+
const model = new Model("DefaultedModel");
|
|
2763
|
+
```
|
|
2764
|
+
|
|
2765
|
+
---
|
|
2766
|
+
|
|
2767
|
+
## 4. Distribution: Packaging as a Plugin
|
|
2768
|
+
|
|
2769
|
+
To publish your solver as an independent npm package (e.g. `@my-org/washi-solver-cplex`):
|
|
2770
|
+
|
|
2771
|
+
### 1. `package.json` Exports Structure
|
|
2772
|
+
```json
|
|
2773
|
+
{
|
|
2774
|
+
"name": "@my-org/washi-solver-cplex",
|
|
2775
|
+
"version": "1.0.0",
|
|
2776
|
+
"type": "module",
|
|
2777
|
+
"main": "./dist/lib/index.js",
|
|
2778
|
+
"types": "./dist/types/index.d.ts",
|
|
2779
|
+
"exports": {
|
|
2780
|
+
".": {
|
|
2781
|
+
"types": "./dist/types/index.d.ts",
|
|
2782
|
+
"import": "./dist/lib/index.js",
|
|
2783
|
+
"default": "./dist/lib/index.js"
|
|
2784
|
+
},
|
|
2785
|
+
"./browser": {
|
|
2786
|
+
"types": "./dist/types/index.d.ts",
|
|
2787
|
+
"import": "./dist/browser/index.js",
|
|
2788
|
+
"default": "./dist/browser/index.js"
|
|
2789
|
+
},
|
|
2790
|
+
"./browser/index.min.js": "./dist/browser/index.min.js"
|
|
2791
|
+
},
|
|
2792
|
+
"peerDependencies": {
|
|
2793
|
+
"@ohtaman/washi": ">=0.1.0"
|
|
2794
|
+
}
|
|
2795
|
+
}
|
|
2796
|
+
```
|
|
2797
|
+
|
|
2798
|
+
### 2. Browser IIFE Build
|
|
2799
|
+
When building the browser standalone script (`.min.js`), configure your bundler (Vite or Rollup) to:
|
|
2800
|
+
- Output format: `iife`
|
|
2801
|
+
- Global name: `WashiCplex`
|
|
2802
|
+
- Rely on `Symbol.for("washi.solverRegistry")` so that `<script src="washi.min.js"></script><script src="washi-cplex.min.js"></script>` automatically connects.
|
|
2803
|
+
|
|
2804
|
+
---
|
|
2805
|
+
|
|
2806
|
+
## 5. Testing Your Custom Solver
|
|
2807
|
+
|
|
2808
|
+
You can test your solver using Vitest or Jest without bundling:
|
|
2809
|
+
|
|
2810
|
+
```typescript
|
|
2811
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2812
|
+
import { Model, expr, maximize, registerSolver, listAvailableSolvers } from "@ohtaman/washi";
|
|
2813
|
+
import { CloudSolver } from "./cloud-solver.js";
|
|
2814
|
+
|
|
2815
|
+
describe("Custom Solver Integration", () => {
|
|
2816
|
+
it("registers in available solvers list", () => {
|
|
2817
|
+
registerSolver("cloud-test", () => new CloudSolver());
|
|
2818
|
+
expect(listAvailableSolvers()).toContain("cloud-test");
|
|
2819
|
+
});
|
|
2820
|
+
|
|
2821
|
+
it("propagates primal values and duals into WaSHI Model", async () => {
|
|
2822
|
+
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
|
|
2823
|
+
new Response(
|
|
2824
|
+
JSON.stringify({
|
|
2825
|
+
status: "OPTIMAL",
|
|
2826
|
+
objectiveValue: 34,
|
|
2827
|
+
values: { x: 2, y: 10 },
|
|
2828
|
+
duals: { budget: 2 },
|
|
2829
|
+
}),
|
|
2830
|
+
{ status: 200, headers: { "Content-Type": "application/json" } }
|
|
2831
|
+
)
|
|
2832
|
+
);
|
|
2833
|
+
|
|
2834
|
+
try {
|
|
2835
|
+
const model = new Model({ solver: new CloudSolver() });
|
|
2836
|
+
const x = model.continuous("x", { lb: 0, ub: 10 });
|
|
2837
|
+
const y = model.continuous("y", { lb: 0, ub: 10 });
|
|
2838
|
+
model.objective(maximize(expr`2 * ${x} + 3 * ${y}`));
|
|
2839
|
+
model.constraint(expr`${x} + ${y} <= 12`, "budget");
|
|
2840
|
+
|
|
2841
|
+
const status = await model.solve();
|
|
2842
|
+
expect(status).toBe("Optimal");
|
|
2843
|
+
expect(x.value).toBe(2);
|
|
2844
|
+
const c1 = model.getConstraintByName("budget");
|
|
2845
|
+
expect(c1?.dual).toBe(2);
|
|
2846
|
+
} finally {
|
|
2847
|
+
fetchSpy.mockRestore();
|
|
2848
|
+
}
|
|
2849
|
+
});
|
|
2850
|
+
});
|
|
2851
|
+
```
|
|
2852
|
+
|
|
2853
|
+
---
|
|
2854
|
+
|
|
2855
|
+
# Examples & API Finder
|
|
2856
|
+
|
|
2857
|
+
Explore runnable optimization and logic formulations built with WaSHI. Every example can be executed directly inside its documentation page, or edited in the [Playground](https://ohtaman.github.io/washi/demo/).
|
|
2858
|
+
|
|
2859
|
+
## Examples
|
|
2860
|
+
|
|
2861
|
+
| Example | Problem Class | Solver | Key WaSHI APIs | Description | Code Editor |
|
|
2862
|
+
| :--- | :--- | :--- | :--- | :--- | :--- |
|
|
2863
|
+
| **[Production Planning](docs/examples/production.md)** | LP / MIP | HiGHS | `m.integer()`, `m.constraint()`, `expr` | Basic variables, tagged template literal equations, and maximizing profit under resource limits. | [Playground](https://ohtaman.github.io/washi/demo/?preset=production) |
|
|
2864
|
+
| **[0-1 Knapsack](docs/examples/knapsack.md)** | Binary MIP | HiGHS | `m.binary(keys)`, `sum()`, `m.constraint()` | 1D dictionary of `Binary` variables and linear aggregation over item arrays. | [Playground](https://ohtaman.github.io/washi/demo/?preset=knapsack) |
|
|
2865
|
+
| **[Transportation Network](docs/examples/transportation.md)** | LP | HiGHS | `m.continuous(rows, cols)`, `dot()`, `m.constraint()` | 2D matrix indexing (`ship[factory][store]`) and row/column flow balance constraints. | [Playground](https://ohtaman.github.io/washi/demo/?preset=transportation) |
|
|
2866
|
+
| **[Diet & Sensitivity Analysis](docs/examples/diet-sensitivity.md)** | LP | HiGHS | `constr.dual`, `constr.slack`, `var.reducedCost` | Reading shadow prices (bottlenecks), constraint surpluses, and reduced costs after solving. | [Playground](https://ohtaman.github.io/washi/demo/?preset=diet_sensitivity) |
|
|
2867
|
+
| **[Staff Shift Scheduling](docs/examples/shift-scheduling.md)** | Binary MIP + Unsat Core | HiGHS + Z3 | `m.binary(staff, days)`, `check()`, `unsatCore` | 5×7 weekly roster planner (coverage, senior duty, max shifts, day-off requests) and diagnosing conflicting vacation requests with Z3 `unsatCore`. | [Playground](https://ohtaman.github.io/washi/demo/?preset=shift_scheduling) |
|
|
2868
|
+
| **[E-Commerce Coupon Optimizer](docs/examples/ecommerce-coupons.md)** | Indicator MIP | HiGHS | `m.indicator()`, `sum()`, `m.constraint()` | Checkout optimizer using `m.indicator()` for minimum spend thresholds, exclusive vs. stackable rules, and smart filler add-on suggestions. | [Playground](https://ohtaman.github.io/washi/demo/?preset=cart_coupon) |
|
|
2869
|
+
| **[Portfolio Optimization](docs/examples/portfolio-qp.md)** | Convex QP | HiGHS | `m.continuous()`, `m.constraint()`, `dot()`, `QuadExpr` | Markowitz mean-variance risk minimization ($w^T \Sigma w$) using `QuadExpr` subject to return targets. | [Playground](https://ohtaman.github.io/washi/demo/?preset=portfolio_qp) |
|
|
2870
|
+
| **[SMT Security Policies](docs/examples/smt-security.md)** | SMT Logic | Z3 | `m.bool()`, `m.constraint()`, `m.check()` | Propositional variables (`BoolVar`) and logical connectives (`implies`, `and`, `not`, `xor`) mixed with integer bounds. | [Playground](https://ohtaman.github.io/washi/demo/?preset=smt_sat_scheduling) |
|
|
2871
|
+
| **[Conflict Diagnosis (`unsatCore`)](docs/examples/unsat-core.md)** | Unsat Core | Z3 | `m.constraint()`, `m.check()`, `m.unsatCore` | Extracting a conflicting constraint subset (`unsatCore`) when staffing and budget rules contradict each other. | [Playground](https://ohtaman.github.io/washi/demo/?preset=conflict_diagnosis_unsat) |
|
|
2872
|
+
| **[Vehicle Routing (CVRP)](docs/examples/vehicle-routing.md)** | Combinatorial OR / Dantzig-Wolfe | HiGHS | `m.solveColumnGeneration()`, `pricing` | Dynamic route column generation from customer dual shadow prices to minimize total fleet vehicles. | [Decomposition Guide](docs/guide/decomposition.md) |
|
|
2873
|
+
| **[Traveling Salesperson (TSP)](docs/examples/traveling-salesperson.md)** | Combinatorial OR / Benders | HiGHS | `m.solveBenders()`, `subproblem` | Subtour elimination on degree-assignment relaxation using dynamic Benders feasibility cuts. | [Decomposition Guide](docs/guide/decomposition.md) |
|
|
2874
|
+
|
|
2875
|
+
## Find by WaSHI API (Reverse Index)
|
|
2876
|
+
|
|
2877
|
+
Looking for a specific modeling method or solver capability? Jump straight to the example that demonstrates it:
|
|
2878
|
+
|
|
2879
|
+
| Feature / API | Description | Example Page | Node.js Source |
|
|
2880
|
+
| :--- | :--- | :--- | :--- |
|
|
2881
|
+
| ``m.constraint(expr`...`)`` | Tagged template literal constraints | [Production Planning](docs/examples/production.md) | [`01-production-planning.ts`](https://github.com/ohtaman/washi/blob/main/examples/node/01-production-planning.ts) |
|
|
2882
|
+
| `m.binary(keys)` / `m.continuous(keys)` | 1D dictionary of indexed variables | [0-1 Knapsack](docs/examples/knapsack.md) | [`02-knapsack-problem.ts`](https://github.com/ohtaman/washi/blob/main/examples/node/02-knapsack-problem.ts) |
|
|
2883
|
+
| `m.continuous(rows, cols)` / `m.binary(rows, cols)` | 2D matrix of indexed variables | [Transportation Network](docs/examples/transportation.md), [Shift Scheduling](docs/examples/shift-scheduling.md) | [`09-transportation-problem.ts`](https://github.com/ohtaman/washi/blob/main/examples/node/09-transportation-problem.ts) |
|
|
2884
|
+
| `dot(matrix, vars2d)` / `dot(vector, vars)` | Fast vector/matrix dot product | [Transportation Network](docs/examples/transportation.md), [Portfolio Optimization](docs/examples/portfolio-qp.md) | [`09-transportation-problem.ts`](https://github.com/ohtaman/washi/blob/main/examples/node/09-transportation-problem.ts) |
|
|
2885
|
+
| `constr.dual` & `constr.slack` | Shadow prices & constraint surpluses | [Diet & Sensitivity Analysis](docs/examples/diet-sensitivity.md) | [`10-diet-sensitivity.ts`](https://github.com/ohtaman/washi/blob/main/examples/node/10-diet-sensitivity.ts) |
|
|
2886
|
+
| `var.reducedCost` | Variable reduced costs in LP | [Diet & Sensitivity Analysis](docs/examples/diet-sensitivity.md) | [`10-diet-sensitivity.ts`](https://github.com/ohtaman/washi/blob/main/examples/node/10-diet-sensitivity.ts) |
|
|
2887
|
+
| `m.indicator()` | Big-M conditional constraints (`binVar => constr`) | [E-Commerce Coupon Optimizer](docs/examples/ecommerce-coupons.md) | [`12-ecommerce-coupon-optimizer.ts`](https://github.com/ohtaman/washi/blob/main/examples/node/12-ecommerce-coupon-optimizer.ts) |
|
|
2888
|
+
| `new QuadExpr()` | Convex quadratic objectives ($x^T Q x$) | [Portfolio Optimization](docs/examples/portfolio-qp.md) | [`03-portfolio-qp.ts`](https://github.com/ohtaman/washi/blob/main/examples/node/03-portfolio-qp.ts) |
|
|
2889
|
+
| `m.bool()` & `.implies()` | Propositional logic & SMT rules | [SMT Security Policies](docs/examples/smt-security.md) | [`04-smt-security-rules.ts`](https://github.com/ohtaman/washi/blob/main/examples/node/04-smt-security-rules.ts) |
|
|
2890
|
+
| `m.check()` & `m.unsatCore` | Feasibility check & conflict diagnosis | [Conflict Diagnosis](docs/examples/unsat-core.md), [Shift Scheduling](docs/examples/shift-scheduling.md) | [`05-unsat-core-diagnosis.ts`](https://github.com/ohtaman/washi/blob/main/examples/node/05-unsat-core-diagnosis.ts) |
|
|
2891
|
+
| `m.solveColumnGeneration()` | Dantzig-Wolfe Column Generation | [Vehicle Routing](docs/examples/vehicle-routing.md) | [`13-vehicle-routing-column-generation.ts`](https://github.com/ohtaman/washi/blob/main/examples/node/13-vehicle-routing-column-generation.ts) |
|
|
2892
|
+
| `m.solveBenders()` | Benders Decomposition & cut separation | [Traveling Salesperson](docs/examples/traveling-salesperson.md) | [`14-traveling-salesperson-benders.ts`](https://github.com/ohtaman/washi/blob/main/examples/node/14-traveling-salesperson-benders.ts) |
|
|
2893
|
+
| `solver: "glpk"` | Solving LP/MIP with GLPK plugin | [GLPK Solver Guide](docs/solvers/glpk.md) | [`08-glpk-solver-plugin.ts`](https://github.com/ohtaman/washi/blob/main/examples/node/08-glpk-solver-plugin.ts) |
|
|
2894
|
+
|
|
2895
|
+
## Running Examples Locally in Node.js
|
|
2896
|
+
|
|
2897
|
+
All standalone scripts are located in [`examples/node/`](https://github.com/ohtaman/washi/tree/main/examples/node):
|
|
2898
|
+
|
|
2899
|
+
```bash
|
|
2900
|
+
npx tsx examples/node/01-production-planning.ts
|
|
2901
|
+
npx tsx examples/node/02-knapsack-problem.ts
|
|
2902
|
+
npx tsx examples/node/03-portfolio-qp.ts
|
|
2903
|
+
npx tsx examples/node/04-smt-security-rules.ts
|
|
2904
|
+
npx tsx examples/node/05-unsat-core-diagnosis.ts
|
|
2905
|
+
npx tsx examples/node/07-hybrid-smt-mip.ts
|
|
2906
|
+
npx tsx examples/node/08-glpk-solver-plugin.ts
|
|
2907
|
+
npx tsx examples/node/09-transportation-problem.ts
|
|
2908
|
+
npx tsx examples/node/10-diet-sensitivity.ts
|
|
2909
|
+
npx tsx examples/node/11-shift-scheduling.ts
|
|
2910
|
+
npx tsx examples/node/12-ecommerce-coupon-optimizer.ts
|
|
2911
|
+
```
|
|
2912
|
+
|
|
2913
|
+
---
|
|
2914
|
+
|
|
2915
|
+
# Production Planning (LP / MIP)
|
|
2916
|
+
|
|
2917
|
+
| Problem Class | Solver | Key APIs | Links |
|
|
2918
|
+
| :--- | :--- | :--- | :--- |
|
|
2919
|
+
| Linear / Integer Programming (LP / MIP) | HiGHS (default) | `m.integer()`, `m.constraint()`, `expr` | <ul><li><a href="https://ohtaman.github.io/washi/demo/?preset=production">Open in Playground</a></li><li><a href="https://github.com/ohtaman/washi/blob/main/examples/node/01-production-planning.ts">Source Code</a></li></ul> |
|
|
2920
|
+
|
|
2921
|
+
Production planning is a classic Linear Programming problem: determine the optimal quantity of each product to manufacture in order to maximize total profit while respecting resource constraints (raw materials, machine time, labor hours).
|
|
2922
|
+
|
|
2923
|
+
## Mathematical Formulation
|
|
2924
|
+
|
|
2925
|
+
Let $x$ be the number of chairs and $y$ be the number of tables.
|
|
2926
|
+
|
|
2927
|
+
$$\begin{aligned}
|
|
2928
|
+
\text{Maximize} \quad & 45x + 80y & (\text{Profit}) \\
|
|
2929
|
+
\text{Subject to} \quad & 5x + 20y \le 400 & (\text{Mahogany Wood limit}) \\
|
|
2930
|
+
& 10x + 15y \le 450 & (\text{Carpentry Labor limit}) \\
|
|
2931
|
+
& x, y \ge 0, \quad x, y \in \mathbb{Z} & (\text{Integer Quantities})
|
|
2932
|
+
\end{aligned}$$
|
|
2933
|
+
|
|
2934
|
+
## Implementation in WaSHI
|
|
2935
|
+
|
|
2936
|
+
```typescript
|
|
2937
|
+
import { Model, expr, maximize } from "@ohtaman/washi";
|
|
2938
|
+
|
|
2939
|
+
async function runProductionOptimization() {
|
|
2940
|
+
const m = new Model("FactoryProduction");
|
|
2941
|
+
|
|
2942
|
+
// Decision variables: non-negative integer quantities
|
|
2943
|
+
const chairs = m.integer("chairs", { lb: 0 });
|
|
2944
|
+
const tables = m.integer("tables", { lb: 0 });
|
|
2945
|
+
|
|
2946
|
+
// Objective: Maximize total profit ($45 per chair, $80 per table)
|
|
2947
|
+
m.objective(maximize(expr`45 * ${chairs} + 80 * ${tables}`));
|
|
2948
|
+
|
|
2949
|
+
// Resource constraints: declarative formulas via expr and fluent constraint()
|
|
2950
|
+
m.constraint(expr`5 * ${chairs} + 20 * ${tables} <= 400`, "mahogany_limit")
|
|
2951
|
+
.constraint(expr`10 * ${chairs} + 15 * ${tables} <= 450`, "labor_limit");
|
|
2952
|
+
|
|
2953
|
+
const status = await m.solve();
|
|
2954
|
+
|
|
2955
|
+
console.log(`Status: ${status}`);
|
|
2956
|
+
console.log(`Optimal Profit: $${m.objectiveValue}`);
|
|
2957
|
+
console.log(`Chairs: ${chairs.value}, Tables: ${tables.value}`);
|
|
2958
|
+
}
|
|
2959
|
+
|
|
2960
|
+
runProductionOptimization();
|
|
2961
|
+
```
|
|
2962
|
+
|
|
2963
|
+
---
|
|
2964
|
+
|
|
2965
|
+
# 0-1 Knapsack Problem (Binary MIP)
|
|
2966
|
+
|
|
2967
|
+
| Problem Class | Solver | Key APIs | Links |
|
|
2968
|
+
| :--- | :--- | :--- | :--- |
|
|
2969
|
+
| 0-1 Binary MIP | HiGHS (default) | `m.binary()`, `m.constraint()`, `expr`, `maximize`, `dot` | <ul><li><a href="https://ohtaman.github.io/washi/demo/?preset=knapsack">Open in Playground</a></li><li><a href="https://github.com/ohtaman/washi/blob/main/examples/node/02-knapsack-problem.ts">Source Code</a></li></ul> |
|
|
2970
|
+
|
|
2971
|
+
The 0-1 Knapsack problem is an essential combinatorial optimization problem: given a set of items, each with a weight and a value, determine which items to include in a collection so that the total weight does not exceed a given limit and the total value is maximized.
|
|
2972
|
+
|
|
2973
|
+
## Mathematical Formulation
|
|
2974
|
+
|
|
2975
|
+
Let $x_i \in \{0, 1\}$ be binary decision variables indicating whether item $i$ is selected ($x_i = 1$) or not ($x_i = 0$):
|
|
2976
|
+
|
|
2977
|
+
$$\begin{aligned}
|
|
2978
|
+
\text{Maximize} \quad & \sum_{i} v_i x_i & (\text{Total Value}) \\
|
|
2979
|
+
\text{Subject to} \quad & \sum_{i} w_i x_i \le W & (\text{Weight Capacity}) \\
|
|
2980
|
+
& x_i \in \{0, 1\} & (\text{Binary Decisions})
|
|
2981
|
+
\end{aligned}$$
|
|
2982
|
+
|
|
2983
|
+
## Implementation in WaSHI
|
|
2984
|
+
|
|
2985
|
+
```typescript
|
|
2986
|
+
import { Model, maximize, dot, expr } from "@ohtaman/washi";
|
|
2987
|
+
|
|
2988
|
+
async function solveKnapsack() {
|
|
2989
|
+
const m = new Model("Knapsack");
|
|
2990
|
+
|
|
2991
|
+
const items = [
|
|
2992
|
+
{ name: "Laptop", weight: 3, value: 50 },
|
|
2993
|
+
{ name: "Camera", weight: 2, value: 40 },
|
|
2994
|
+
{ name: "Drone", weight: 4, value: 65 },
|
|
2995
|
+
{ name: "FirstAid", weight: 1, value: 15 },
|
|
2996
|
+
{ name: "Tent", weight: 5, value: 70 },
|
|
2997
|
+
];
|
|
2998
|
+
const maxCapacity = 7; // kg
|
|
2999
|
+
|
|
3000
|
+
// Create binary variables: x[name] (0 or 1)
|
|
3001
|
+
const x = m.binary(items.map((it) => it.name));
|
|
3002
|
+
|
|
3003
|
+
const values = Object.fromEntries(items.map((it) => [it.name, it.value]));
|
|
3004
|
+
const weights = Object.fromEntries(items.map((it) => [it.name, it.weight]));
|
|
3005
|
+
|
|
3006
|
+
// Maximize total value via dot product
|
|
3007
|
+
m.objective(maximize(dot(values, x)));
|
|
3008
|
+
|
|
3009
|
+
// Weight capacity constraint using natural expr and constraint() syntax
|
|
3010
|
+
m.constraint(expr`${dot(weights, x)} <= ${maxCapacity}`, "capacity_limit");
|
|
3011
|
+
|
|
3012
|
+
const status = await m.solve();
|
|
3013
|
+
|
|
3014
|
+
console.log(`Status: ${status}`);
|
|
3015
|
+
console.log(`Max Total Value: $${m.objectiveValue}`);
|
|
3016
|
+
console.log("Selected Items:", m.getValues(x));
|
|
3017
|
+
}
|
|
3018
|
+
|
|
3019
|
+
solveKnapsack();
|
|
3020
|
+
```
|
|
3021
|
+
|
|
3022
|
+
---
|
|
3023
|
+
|
|
3024
|
+
# Transportation & Allocation Network (LP)
|
|
3025
|
+
|
|
3026
|
+
| Problem Class | Solver | Key APIs | Links |
|
|
3027
|
+
| :--- | :--- | :--- | :--- |
|
|
3028
|
+
| Linear Programming (LP) | HiGHS (default) | `m.continuous()`, `m.constraint()`, `expr`, `minimize`, `dot` | <ul><li><a href="https://ohtaman.github.io/washi/demo/?preset=transportation">Open in Playground</a></li><li><a href="https://github.com/ohtaman/washi/blob/main/examples/node/09-transportation-problem.ts">Source Code</a></li></ul> |
|
|
3029
|
+
|
|
3030
|
+
The transportation problem is one of the most fundamental network optimization models: determine how many units to ship from each supply origin (factory) to each destination (store) to minimize total shipping cost while respecting factory supply limits and meeting store demands.
|
|
3031
|
+
|
|
3032
|
+
This example demonstrates how to construct a **2D matrix of decision variables** using `m.continuous(factories, stores)` and write aggregated constraints declaratively via `dot()` and `m.constraint(expr`...`)`.
|
|
3033
|
+
|
|
3034
|
+
## Problem Inputs
|
|
3035
|
+
|
|
3036
|
+
We have **3 factories** (`Tokyo`, `Nagoya`, `Osaka`) shipping to **4 regional stores** (`North`, `East`, `West`, `South`):
|
|
3037
|
+
|
|
3038
|
+
| Factory \ Store | North ($/unit) | East ($/unit) | West ($/unit) | South ($/unit) | Supply Capacity |
|
|
3039
|
+
| :--- | :---: | :---: | :---: | :---: | :---: |
|
|
3040
|
+
| **Tokyo** | $4 | $2 | $7 | $8 | **120** units |
|
|
3041
|
+
| **Nagoya** | $6 | $4 | $3 | $5 | **100** units |
|
|
3042
|
+
| **Osaka** | $9 | $7 | $2 | $3 | **80** units |
|
|
3043
|
+
| **Store Demand** | **70** units | **90** units | **60** units | **60** units | Total: 280 / 300 |
|
|
3044
|
+
|
|
3045
|
+
## Mathematical Formulation
|
|
3046
|
+
|
|
3047
|
+
Let $x_{f, s} \ge 0$ be the quantity shipped from factory $f \in F$ to store $s \in S$, and $c_{f, s}$ be the unit shipping cost.
|
|
3048
|
+
|
|
3049
|
+
$$\begin{aligned}
|
|
3050
|
+
\text{Minimize} \quad & \sum_{f \in F} \sum_{s \in S} c_{f,s} \, x_{f,s} \\
|
|
3051
|
+
\text{Subject to} \quad & \sum_{s \in S} x_{f,s} \le \text{Supply}_f \quad \forall f \in F \quad (\text{Factory capacity}) \\
|
|
3052
|
+
& \sum_{f \in F} x_{f,s} \ge \text{Demand}_s \quad \forall s \in S \quad (\text{Store demand}) \\
|
|
3053
|
+
& x_{f,s} \ge 0 \quad \forall f \in F, s \in S
|
|
3054
|
+
\end{aligned}$$
|
|
3055
|
+
|
|
3056
|
+
## Implementation in WaSHI
|
|
3057
|
+
|
|
3058
|
+
```typescript
|
|
3059
|
+
import { Model, sum, dot, minimize, expr } from "@ohtaman/washi";
|
|
3060
|
+
|
|
3061
|
+
const factories = ["Tokyo", "Nagoya", "Osaka"] as const;
|
|
3062
|
+
const stores = ["North", "East", "West", "South"] as const;
|
|
3063
|
+
|
|
3064
|
+
const shippingCost = {
|
|
3065
|
+
Tokyo: { North: 4, East: 2, West: 7, South: 8 },
|
|
3066
|
+
Nagoya: { North: 6, East: 4, West: 3, South: 5 },
|
|
3067
|
+
Osaka: { North: 9, East: 7, West: 2, South: 3 },
|
|
3068
|
+
};
|
|
3069
|
+
|
|
3070
|
+
const supplies = { Tokyo: 120, Nagoya: 100, Osaka: 80 };
|
|
3071
|
+
const demands = { North: 70, East: 90, West: 60, South: 60 };
|
|
3072
|
+
|
|
3073
|
+
async function runTransportation() {
|
|
3074
|
+
const m = new Model("TransportationNetwork");
|
|
3075
|
+
|
|
3076
|
+
// 1. Create 2D continuous variables: ship[factory][store] >= 0
|
|
3077
|
+
const ship = m.continuous([...factories], [...stores], { lb: 0 });
|
|
3078
|
+
|
|
3079
|
+
// 2. Objective: Minimize total shipping cost across all routes via 2D dot product
|
|
3080
|
+
m.objective(minimize(dot(shippingCost, ship)));
|
|
3081
|
+
|
|
3082
|
+
// 3. Supply constraints (row sums): outgoing from f <= supplies[f]
|
|
3083
|
+
for (const f of factories) {
|
|
3084
|
+
m.constraint(
|
|
3085
|
+
expr`${sum(stores, (s) => ship[f][s])} <= ${supplies[f]}`,
|
|
3086
|
+
`supply_${f}`
|
|
3087
|
+
);
|
|
3088
|
+
}
|
|
3089
|
+
|
|
3090
|
+
// 4. Demand constraints (column sums): incoming to s >= demands[s]
|
|
3091
|
+
for (const s of stores) {
|
|
3092
|
+
m.constraint(
|
|
3093
|
+
expr`${sum(factories, (f) => ship[f][s])} >= ${demands[s]}`,
|
|
3094
|
+
`demand_${s}`
|
|
3095
|
+
);
|
|
3096
|
+
}
|
|
3097
|
+
|
|
3098
|
+
const status = await m.solve();
|
|
3099
|
+
console.log(`Status: ${status}, Total Shipping Cost: $${m.objectiveValue}`);
|
|
3100
|
+
for (const f of factories) {
|
|
3101
|
+
const routes = stores
|
|
3102
|
+
.map((s) => `${s}:${Math.round(ship[f][s].value ?? 0)}`)
|
|
3103
|
+
.join(", ");
|
|
3104
|
+
console.log(` ${f.padEnd(7)} -> ${routes}`);
|
|
3105
|
+
}
|
|
3106
|
+
}
|
|
3107
|
+
|
|
3108
|
+
runTransportation();
|
|
3109
|
+
```
|
|
3110
|
+
|
|
3111
|
+
---
|
|
3112
|
+
|
|
3113
|
+
# Diet Problem & Sensitivity Analysis (LP Duals & Slacks)
|
|
3114
|
+
|
|
3115
|
+
| Problem Class | Solver | Key APIs | Links |
|
|
3116
|
+
| :--- | :--- | :--- | :--- |
|
|
3117
|
+
| Linear Programming (LP) | HiGHS (default) | `m.continuous()`, `m.constraint()`, `expr`, `minimize`, `dot` | <ul><li><a href="https://ohtaman.github.io/washi/demo/?preset=diet_sensitivity">Open in Playground</a></li><li><a href="https://github.com/ohtaman/washi/blob/main/examples/node/10-diet-sensitivity.ts">Source Code</a></li></ul> |
|
|
3118
|
+
|
|
3119
|
+
When solving a Linear Programming model, the optimal variable values (`var.value`) are only half the story. In practice, decision-makers need to know:
|
|
3120
|
+
|
|
3121
|
+
1. **Bottlenecks (`constr.dual` — Shadow Price):** Which minimum requirements are actively driving up the total cost, and by how much per unit?
|
|
3122
|
+
2. **Surpluses (`constr.slack`):** Which constraints have room to spare?
|
|
3123
|
+
3. **Reduced Costs (`var.reducedCost`):** For items not selected in the optimal plan, how much cheaper would they need to become before entering the solution?
|
|
3124
|
+
|
|
3125
|
+
## Problem Inputs
|
|
3126
|
+
|
|
3127
|
+
We choose daily servings ($0 \le x_i \le 4$) among 6 food items to meet 4 daily nutritional minimums (`Calories >= 2000 kcal`, `Protein >= 105 g`, `Calcium >= 1000 mg`, `Iron >= 18 mg`) at minimum cost:
|
|
3128
|
+
|
|
3129
|
+
| Food Item | Cost / Serving | Calories (kcal) | Protein (g) | Calcium (mg) | Iron (mg) | Max Servings |
|
|
3130
|
+
| :--- | :---: | :---: | :---: | :---: | :---: | :---: |
|
|
3131
|
+
| **Oats** | $0.60 | 150 | 5 | 20 | 1.8 | 4.0 |
|
|
3132
|
+
| **Chicken** | $2.40 | 220 | 32 | 15 | 1.2 | 4.0 |
|
|
3133
|
+
| **Eggs** | $0.80 | 140 | 12 | 50 | 1.7 | 4.0 |
|
|
3134
|
+
| **Milk** | $0.90 | 120 | 8 | 280 | 0.1 | 4.0 |
|
|
3135
|
+
| **Spinach** | $1.50 | 40 | 4 | 120 | 3.5 | 4.0 |
|
|
3136
|
+
| **Salmon** | $3.60 | 200 | 25 | 20 | 0.8 | 4.0 |
|
|
3137
|
+
|
|
3138
|
+
## Implementation in WaSHI
|
|
3139
|
+
|
|
3140
|
+
```typescript
|
|
3141
|
+
import { Model, minimize, dot, expr } from "@ohtaman/washi";
|
|
3142
|
+
|
|
3143
|
+
const foods = [
|
|
3144
|
+
{ name: "Oats", cost: 0.60, calories: 150, protein: 5, calcium: 20, iron: 1.8 },
|
|
3145
|
+
{ name: "Chicken", cost: 2.40, calories: 220, protein: 32, calcium: 15, iron: 1.2 },
|
|
3146
|
+
{ name: "Eggs", cost: 0.80, calories: 140, protein: 12, calcium: 50, iron: 1.7 },
|
|
3147
|
+
{ name: "Milk", cost: 0.90, calories: 120, protein: 8, calcium: 280, iron: 0.1 },
|
|
3148
|
+
{ name: "Spinach", cost: 1.50, calories: 40, protein: 4, calcium: 120, iron: 3.5 },
|
|
3149
|
+
{ name: "Salmon", cost: 3.60, calories: 200, protein: 25, calcium: 20, iron: 0.8 },
|
|
3150
|
+
];
|
|
3151
|
+
|
|
3152
|
+
async function runDietSensitivity() {
|
|
3153
|
+
const m = new Model("DietSensitivity");
|
|
3154
|
+
|
|
3155
|
+
// Continuous servings per day (0 to 4 servings max per food)
|
|
3156
|
+
const srv = m.continuous(foods.map((f) => f.name), { lb: 0, ub: 4 });
|
|
3157
|
+
|
|
3158
|
+
const costs = Object.fromEntries(foods.map((f) => [f.name, f.cost]));
|
|
3159
|
+
const cals = Object.fromEntries(foods.map((f) => [f.name, f.calories]));
|
|
3160
|
+
const prots = Object.fromEntries(foods.map((f) => [f.name, f.protein]));
|
|
3161
|
+
const calcs = Object.fromEntries(foods.map((f) => [f.name, f.calcium]));
|
|
3162
|
+
const irons = Object.fromEntries(foods.map((f) => [f.name, f.iron]));
|
|
3163
|
+
|
|
3164
|
+
// Objective: Minimize total daily cost via dot product
|
|
3165
|
+
m.objective(minimize(dot(costs, srv)));
|
|
3166
|
+
|
|
3167
|
+
// Nutritional constraints defined declaratively with expr and constraint()
|
|
3168
|
+
m.constraint(expr`${dot(cals, srv)} >= 2000`, "min_calories")
|
|
3169
|
+
.constraint(expr`${dot(prots, srv)} >= 105`, "min_protein")
|
|
3170
|
+
.constraint(expr`${dot(calcs, srv)} >= 1000`, "min_calcium")
|
|
3171
|
+
.constraint(expr`${dot(irons, srv)} >= 18`, "min_iron");
|
|
3172
|
+
|
|
3173
|
+
const status = await m.solve();
|
|
3174
|
+
|
|
3175
|
+
console.log(`Status: ${status}, Minimum Daily Cost: $${m.objectiveValue?.toFixed(2)}`);
|
|
3176
|
+
console.log("--- Food Servings & Reduced Costs ---");
|
|
3177
|
+
for (const f of foods) {
|
|
3178
|
+
const v = srv[f.name];
|
|
3179
|
+
console.log(` ${f.name.padEnd(8)}: value=${v.value?.toFixed(2)}, reducedCost=${v.reducedCost?.toFixed(4)}`);
|
|
3180
|
+
}
|
|
3181
|
+
console.log("--- Nutritional Constraints (Dual & Slack) ---");
|
|
3182
|
+
for (const name of ["min_calories", "min_protein", "min_calcium", "min_iron"]) {
|
|
3183
|
+
const c = m.getConstraintByName(name);
|
|
3184
|
+
if (c) {
|
|
3185
|
+
console.log(` ${c.name.padEnd(13)}: dual=$${c.dual?.toFixed(4)}, slack=${Math.abs(c.slack ?? 0).toFixed(2)}`);
|
|
3186
|
+
}
|
|
3187
|
+
}
|
|
3188
|
+
}
|
|
3189
|
+
|
|
3190
|
+
runDietSensitivity();
|
|
3191
|
+
```
|
|
3192
|
+
|
|
3193
|
+
## Interpretation of Results
|
|
3194
|
+
|
|
3195
|
+
- **Bottleneck constraints (`slack == 0`, `dual > 0`):** Calories (`2000 kcal`) and Iron (`18 mg`) are tight at their lower bounds. Increasing the daily Iron requirement by 1 mg increases the minimum daily diet cost by **$0.3241**.
|
|
3196
|
+
- **Constraints with surplus (`slack > 0`, `dual == 0`):** Protein (`+46.46 g` surplus) and Calcium (`+483.02 mg` surplus) are already exceeded with room to spare, so their shadow prices are `$0`.
|
|
3197
|
+
- **Reduced cost (`Salmon`):** Unselected `Salmon` (`0.00` servings) has a reduced cost of `+1.5125`, meaning its price per serving would need to drop by at least **$1.51** before it enters the optimal daily menu.
|
|
3198
|
+
|
|
3199
|
+
---
|
|
3200
|
+
|
|
3201
|
+
# Staff Shift Scheduling & Conflict Diagnosis (MIP + Z3)
|
|
3202
|
+
|
|
3203
|
+
| Problem Class | Solver | Key APIs | Links |
|
|
3204
|
+
| :--- | :--- | :--- | :--- |
|
|
3205
|
+
| Binary MIP & SMT Unsat Core | HiGHS + Z3 | `m.binary(staff, days)`, `m.constraint()`, `expr`, `minimize`, `dot`, `m.check()` | <ul><li><a href="https://ohtaman.github.io/washi/demo/?preset=shift_scheduling">Playground (Roster)</a></li><li><a href="https://ohtaman.github.io/washi/demo/?preset=shift_conflict_unsat">Playground (Conflict)</a></li><li><a href="https://github.com/ohtaman/washi/blob/main/examples/node/11-shift-scheduling.ts">Source Code</a></li></ul> |
|
|
3206
|
+
|
|
3207
|
+
Staff shift scheduling is a classic real-world application of Mixed-Integer Programming in SaaS and workforce management tools. However, in real applications, **staff vacation requests frequently collide with minimum coverage or leadership rules**, causing the solver to return `Infeasible`.
|
|
3208
|
+
|
|
3209
|
+
This example demonstrates a practical production workflow:
|
|
3210
|
+
1. **Try HiGHS MIP Optimization First:** Solve the shift model under the given vacation requests and operational constraints.
|
|
3211
|
+
2. **Accept Solution if Optimal:** If a feasible roster is found, display the schedule and minimized payroll cost without unnecessary diagnostic overhead.
|
|
3212
|
+
3. **Diagnose with Z3 `unsatCore` Only When Infeasible:** If HiGHS reports `Infeasible`, diagnose the **exact same model and constraints** with Z3 to isolate the conflicting constraint subset (`unsatCore`), pinpointing which day's coverage rules and which staff requests collided.
|
|
3213
|
+
|
|
3214
|
+
> [!IMPORTANT]
|
|
3215
|
+
> **Vacation Requests are Hard Constraints:**
|
|
3216
|
+
> In this model, vacation requests are enforced as strict hard constraints (`shift[staff][day] == 0`), not soft constraints with penalty relaxation. When requests collide with mandatory staffing requirements, the model is strictly infeasible.
|
|
3217
|
+
|
|
3218
|
+
## Scenario & Rules
|
|
3219
|
+
|
|
3220
|
+
We schedule **5 staff members** (`Alice`, `Bob`, `Charlie`, `Diana`, `Evan`) across **7 days** (`Mon`–`Sun`):
|
|
3221
|
+
|
|
3222
|
+
- **Daily Headcount:** `Mon–Thu`: 3 staff, `Fri`: 4 staff, `Sat`: 3 staff, `Sun`: 2 staff.
|
|
3223
|
+
- **Senior Duty:** At least 1 senior staff member (`Alice` or `Bob`) must be on duty every day.
|
|
3224
|
+
- **Work Limit:** Each staff member works at most **5 days** per week.
|
|
3225
|
+
- **Weekend Premium:** Weekend shifts (`Sat`, `Sun`) incur an extra `+$30` shift premium on top of base daily wage.
|
|
3226
|
+
|
|
3227
|
+
We evaluate two distinct, named scenarios with different vacation inputs to illustrate the full workflow:
|
|
3228
|
+
- **Scenario A (Balanced Requests — Feasible):** Alice (Sun), Bob (Mon), Charlie (Sat), Diana (Wed). All constraints can be satisfied simultaneously. HiGHS outputs an optimal roster.
|
|
3229
|
+
- **Scenario B (Conflicting Requests — Infeasible & Diagnosis):** Alice (Sat), Bob (Sat), Charlie (Sat), Diana (Wed). Because both senior leaders (Alice and Bob) request Saturday off simultaneously, the rule requiring at least 1 senior on duty every day cannot be satisfied. HiGHS reports `Infeasible`, triggering Z3 conflict diagnosis.
|
|
3230
|
+
|
|
3231
|
+
## Implementation in WaSHI
|
|
3232
|
+
|
|
3233
|
+
```typescript
|
|
3234
|
+
import { Model, sum, dot, minimize, expr } from "@ohtaman/washi";
|
|
3235
|
+
import "@ohtaman/washi/solvers/z3";
|
|
3236
|
+
|
|
3237
|
+
const days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] as const;
|
|
3238
|
+
type Day = (typeof days)[number];
|
|
3239
|
+
|
|
3240
|
+
const staffList = [
|
|
3241
|
+
{ name: "Alice", isSenior: true, dailyWage: 220, maxShifts: 5 },
|
|
3242
|
+
{ name: "Bob", isSenior: true, dailyWage: 210, maxShifts: 5 },
|
|
3243
|
+
{ name: "Charlie", isSenior: false, dailyWage: 160, maxShifts: 5 },
|
|
3244
|
+
{ name: "Diana", isSenior: false, dailyWage: 160, maxShifts: 5 },
|
|
3245
|
+
{ name: "Evan", isSenior: false, dailyWage: 150, maxShifts: 5 },
|
|
3246
|
+
];
|
|
3247
|
+
|
|
3248
|
+
const requiredCoverage: Record<Day, number> = {
|
|
3249
|
+
Mon: 3, Tue: 3, Wed: 3, Thu: 3, Fri: 4, Sat: 3, Sun: 2,
|
|
3250
|
+
};
|
|
3251
|
+
|
|
3252
|
+
function buildShiftModel(name: string, dayOffRequests: Array<{ staff: string; day: Day }>) {
|
|
3253
|
+
const m = new Model(name);
|
|
3254
|
+
|
|
3255
|
+
// 2D binary matrix: shift[staff][day] ∈ {0, 1}
|
|
3256
|
+
const shift = m.binary(staffList.map((s) => s.name), [...days]);
|
|
3257
|
+
|
|
3258
|
+
// Objective: Minimize weekly payroll cost via 2D dot product
|
|
3259
|
+
const costMatrix = Object.fromEntries(
|
|
3260
|
+
staffList.map((s) => [
|
|
3261
|
+
s.name,
|
|
3262
|
+
Object.fromEntries(
|
|
3263
|
+
days.map((d) => [d, s.dailyWage + (d === "Sat" || d === "Sun" ? 30 : 0)])
|
|
3264
|
+
),
|
|
3265
|
+
])
|
|
3266
|
+
);
|
|
3267
|
+
m.objective(minimize(dot(costMatrix, shift)));
|
|
3268
|
+
|
|
3269
|
+
// Rule 1: Daily headcount coverage
|
|
3270
|
+
for (const d of days) {
|
|
3271
|
+
m.constraint(
|
|
3272
|
+
expr`${sum(staffList, (s) => shift[s.name][d])} >= ${requiredCoverage[d]}`,
|
|
3273
|
+
`coverage_${d}`
|
|
3274
|
+
);
|
|
3275
|
+
}
|
|
3276
|
+
|
|
3277
|
+
// Rule 2: At least 1 senior staff member per day
|
|
3278
|
+
const seniors = staffList.filter((s) => s.isSenior);
|
|
3279
|
+
for (const d of days) {
|
|
3280
|
+
m.constraint(
|
|
3281
|
+
expr`${sum(seniors, (s) => shift[s.name][d])} >= 1`,
|
|
3282
|
+
`senior_duty_${d}`
|
|
3283
|
+
);
|
|
3284
|
+
}
|
|
3285
|
+
|
|
3286
|
+
// Rule 3: Max working days per staff member
|
|
3287
|
+
for (const s of staffList) {
|
|
3288
|
+
m.constraint(
|
|
3289
|
+
expr`${sum(days, (d) => shift[s.name][d])} <= ${s.maxShifts}`,
|
|
3290
|
+
`max_shifts_${s.name}`
|
|
3291
|
+
);
|
|
3292
|
+
}
|
|
3293
|
+
|
|
3294
|
+
// Rule 4: Named day-off requests as HARD constraints (shift[staff][day] == 0)
|
|
3295
|
+
for (const req of dayOffRequests) {
|
|
3296
|
+
m.constraint(
|
|
3297
|
+
expr`${shift[req.staff][req.day]} == 0`,
|
|
3298
|
+
`day_off_${req.staff}_${req.day}`
|
|
3299
|
+
);
|
|
3300
|
+
}
|
|
3301
|
+
|
|
3302
|
+
return { model: m, shift };
|
|
3303
|
+
}
|
|
3304
|
+
|
|
3305
|
+
async function runScenario(
|
|
3306
|
+
title: string,
|
|
3307
|
+
modelName: string,
|
|
3308
|
+
dayOffRequests: Array<{ staff: string; day: Day }>
|
|
3309
|
+
) {
|
|
3310
|
+
console.log(`=== ${title} ===`);
|
|
3311
|
+
console.log(
|
|
3312
|
+
"Hard Vacation Requests: " +
|
|
3313
|
+
dayOffRequests.map((r) => `${r.staff} (${r.day})`).join(", ")
|
|
3314
|
+
);
|
|
3315
|
+
|
|
3316
|
+
const { model, shift } = buildShiftModel(modelName, dayOffRequests);
|
|
3317
|
+
|
|
3318
|
+
// Step 1: Optimize with HiGHS
|
|
3319
|
+
const status = await model.solve();
|
|
3320
|
+
console.log(`HiGHS Optimize Status: ${status}`);
|
|
3321
|
+
|
|
3322
|
+
if (status === "Optimal") {
|
|
3323
|
+
console.log(`Optimal Weekly Payroll: $${model.objectiveValue}`);
|
|
3324
|
+
for (const s of staffList) {
|
|
3325
|
+
const schedule = days.map((d) => (Math.round(shift[s.name][d].value ?? 0) === 1 ? "ON" : " ·"));
|
|
3326
|
+
console.log(` ${s.name.padEnd(8)}: ${schedule.join(" ")}`);
|
|
3327
|
+
}
|
|
3328
|
+
console.log("Schedule resolved successfully (no UNSAT diagnosis needed).\n");
|
|
3329
|
+
} else if (status === "Infeasible") {
|
|
3330
|
+
// Step 2: Diagnose the exact same model and constraints with Z3
|
|
3331
|
+
console.log("HiGHS returned Infeasible! Diagnosing conflict with Z3 unsatCore...");
|
|
3332
|
+
const checkRes = await model.check({ solver: "z3" });
|
|
3333
|
+
console.log(`Z3 Check Status: ${checkRes.status}`);
|
|
3334
|
+
console.log("Conflicting Constraint Subset (unsatCore):", model.unsatCore);
|
|
3335
|
+
console.log(
|
|
3336
|
+
"Note: unsatCore returns a conflicting subset (minimality/order not guaranteed). " +
|
|
3337
|
+
"Independent requests like Diana's Wednesday off are excluded.\n"
|
|
3338
|
+
);
|
|
3339
|
+
}
|
|
3340
|
+
}
|
|
3341
|
+
|
|
3342
|
+
async function main() {
|
|
3343
|
+
// Scenario A: Balanced requests (Solvable)
|
|
3344
|
+
await runScenario(
|
|
3345
|
+
"Scenario A: Balanced Vacation Requests",
|
|
3346
|
+
"FeasibleRoster",
|
|
3347
|
+
[
|
|
3348
|
+
{ staff: "Alice", day: "Sun" },
|
|
3349
|
+
{ staff: "Bob", day: "Mon" },
|
|
3350
|
+
{ staff: "Charlie", day: "Sat" },
|
|
3351
|
+
{ staff: "Diana", day: "Wed" },
|
|
3352
|
+
]
|
|
3353
|
+
);
|
|
3354
|
+
|
|
3355
|
+
// Scenario B: Conflicting Saturday requests (Infeasible)
|
|
3356
|
+
await runScenario(
|
|
3357
|
+
"Scenario B: Conflicting Saturday Requests",
|
|
3358
|
+
"ConflictRoster",
|
|
3359
|
+
[
|
|
3360
|
+
{ staff: "Alice", day: "Sat" }, // Senior
|
|
3361
|
+
{ staff: "Bob", day: "Sat" }, // Senior
|
|
3362
|
+
{ staff: "Charlie", day: "Sat" },
|
|
3363
|
+
{ staff: "Diana", day: "Wed" },
|
|
3364
|
+
]
|
|
3365
|
+
);
|
|
3366
|
+
}
|
|
3367
|
+
|
|
3368
|
+
main();
|
|
3369
|
+
```
|
|
3370
|
+
|
|
3371
|
+
---
|
|
3372
|
+
|
|
3373
|
+
# E-Commerce Cart & Coupon Optimizer (Indicator MIP)
|
|
3374
|
+
|
|
3375
|
+
| Problem Class | Solver | Key APIs | Links |
|
|
3376
|
+
| :--- | :--- | :--- | :--- |
|
|
3377
|
+
| Mixed-Integer Programming (MIP) | HiGHS (default) | `m.binary()`, `m.constraint()`, `m.indicator()`, `expr`, `minimize` | <ul><li><a href="https://ohtaman.github.io/washi/demo/?preset=cart_coupon">Open in Playground</a></li><li><a href="https://github.com/ohtaman/washi/blob/main/examples/node/12-ecommerce-coupon-optimizer.ts">Source Code</a></li></ul> |
|
|
3378
|
+
|
|
3379
|
+
Modern e-commerce checkouts often feature complex promotional rules: minimum spend thresholds, category-specific discounts, stackable vs. mutually exclusive coupons, and maximum coupon limits per order. Running an optimizer directly in the browser (or an edge worker) lets the checkout UI automatically apply the best coupon combination—and even recommend a small "filler" item when crossing a spend threshold lowers the customer's net bill.
|
|
3380
|
+
|
|
3381
|
+
This example demonstrates **`m.indicator(binVar, constraint, { activeValue, M? })`**, which automatically linearizes conditional *"if coupon is selected, minimum spend must hold"* constraints using Big-M reformulation for MIP solvers like HiGHS. When `M` is omitted, WaSHI automatically derives safe, tight Big-M values from finite variable bounds.
|
|
3382
|
+
|
|
3383
|
+
## Scenario & Promotion Rules
|
|
3384
|
+
|
|
3385
|
+
A customer's shopping cart currently contains **$242** of merchandise (**$210** in Tech items and **$32** in Apparel):
|
|
3386
|
+
- **Optional Filler Add-on:** A **$10** Braided USB-C Cable (Tech) can be added to bring the cart total from `$242` to `$252`.
|
|
3387
|
+
- **Available Coupons** (maximum **2 coupons** total per checkout; at most **1 Exclusive** coupon):
|
|
3388
|
+
|
|
3389
|
+
| Coupon Code | Discount | Condition | Stacking Rule |
|
|
3390
|
+
| :--- | :---: | :--- | :--- |
|
|
3391
|
+
| `WELCOME15` | **-$15** | Total cart spend $\ge \$100$ | **Stackable** |
|
|
3392
|
+
| `TECH30` | **-$30** | Tech category spend $\ge \$200$ | **Exclusive** (cannot combine with `MEGA45_250`) |
|
|
3393
|
+
| `MEGA45_250` | **-$45** | Total cart spend $\ge \$250$ | **Exclusive** (cannot combine with `TECH30`) |
|
|
3394
|
+
| `MEMBER10` | **-$10** | Total cart spend $\ge \$150$ | **Stackable** |
|
|
3395
|
+
|
|
3396
|
+
Without adding the `$10` cable, the cart (`$242`) cannot use `MEGA45_250` and pays `$242 - $30 (TECH30) - $15 (WELCOME15) = $197`.
|
|
3397
|
+
By adding the `$10` cable (`$252` gross), the customer unlocks `MEGA45_250` (`-$45`) + `WELCOME15` (`-$15`) for a net total of **$192**—saving an extra **$5** while getting an extra item!
|
|
3398
|
+
|
|
3399
|
+
## Implementation in WaSHI
|
|
3400
|
+
|
|
3401
|
+
```typescript
|
|
3402
|
+
import { Model, sum, minimize, expr } from "@ohtaman/washi";
|
|
3403
|
+
|
|
3404
|
+
async function optimizeCheckoutCart() {
|
|
3405
|
+
const m = new Model("CartCouponOptimizer");
|
|
3406
|
+
|
|
3407
|
+
const baseCartSubtotal = 242;
|
|
3408
|
+
const baseTechSubtotal = 210;
|
|
3409
|
+
|
|
3410
|
+
// Binary decision: add optional $10 USB-C cable to unlock $250 tier?
|
|
3411
|
+
const addUsbCable = m.binary("addon_USB_Cable");
|
|
3412
|
+
const usbCablePrice = 10;
|
|
3413
|
+
|
|
3414
|
+
const coupons = [
|
|
3415
|
+
{ id: "WELCOME15", discount: 15, minCart: 100, minTech: 0, stackable: true },
|
|
3416
|
+
{ id: "TECH30", discount: 30, minCart: 0, minTech: 200, stackable: false },
|
|
3417
|
+
{ id: "MEGA45_250", discount: 45, minCart: 250, minTech: 0, stackable: false },
|
|
3418
|
+
{ id: "MEMBER10", discount: 10, minCart: 150, minTech: 0, stackable: true },
|
|
3419
|
+
];
|
|
3420
|
+
|
|
3421
|
+
// Binary decision variables for each coupon
|
|
3422
|
+
const useCoupon = m.binary(coupons.map((c) => c.id));
|
|
3423
|
+
|
|
3424
|
+
const merchSpend = addUsbCable.mul(usbCablePrice).add(baseCartSubtotal);
|
|
3425
|
+
const techSpend = addUsbCable.mul(usbCablePrice).add(baseTechSubtotal);
|
|
3426
|
+
|
|
3427
|
+
// 1. Indicator constraints: If useCoupon[id] == 1, minimum spend threshold must hold
|
|
3428
|
+
for (const cpn of coupons) {
|
|
3429
|
+
if (cpn.minCart > 0) {
|
|
3430
|
+
m.indicator(useCoupon[cpn.id], merchSpend.ge(cpn.minCart), {
|
|
3431
|
+
activeValue: 1,
|
|
3432
|
+
M: 500,
|
|
3433
|
+
name: `min_spend_${cpn.id}`,
|
|
3434
|
+
});
|
|
3435
|
+
}
|
|
3436
|
+
if (cpn.minTech > 0) {
|
|
3437
|
+
m.indicator(useCoupon[cpn.id], techSpend.ge(cpn.minTech), {
|
|
3438
|
+
activeValue: 1,
|
|
3439
|
+
M: 500,
|
|
3440
|
+
name: `min_tech_${cpn.id}`,
|
|
3441
|
+
});
|
|
3442
|
+
}
|
|
3443
|
+
}
|
|
3444
|
+
|
|
3445
|
+
// 2. Mutual exclusion among non-stackable ("Exclusive") coupons
|
|
3446
|
+
const exclusiveCoupons = coupons.filter((c) => !c.stackable);
|
|
3447
|
+
m.constraint(
|
|
3448
|
+
expr`${sum(exclusiveCoupons, (c) => useCoupon[c.id])} <= 1`,
|
|
3449
|
+
"at_most_one_exclusive"
|
|
3450
|
+
);
|
|
3451
|
+
|
|
3452
|
+
// 3. Max 2 coupons per order
|
|
3453
|
+
m.constraint(
|
|
3454
|
+
expr`${sum(coupons, (c) => useCoupon[c.id])} <= 2`,
|
|
3455
|
+
"max_two_coupons"
|
|
3456
|
+
);
|
|
3457
|
+
|
|
3458
|
+
// Objective: Minimize final net payable amount (merchSpend - totalDiscounts)
|
|
3459
|
+
const totalDiscount = sum(coupons, (c) => useCoupon[c.id].mul(c.discount));
|
|
3460
|
+
m.objective(minimize(merchSpend.sub(totalDiscount)));
|
|
3461
|
+
|
|
3462
|
+
const status = await m.solve();
|
|
3463
|
+
console.log(`Status: ${status}, Optimal Net Payable: $${m.objectiveValue}`);
|
|
3464
|
+
console.log(`Recommended Add-on (+$10 USB-C Cable): ${(addUsbCable.value ?? 0) > 0.5 ? "YES" : "NO"}`);
|
|
3465
|
+
for (const cpn of coupons) {
|
|
3466
|
+
const applied = (useCoupon[cpn.id].value ?? 0) > 0.5;
|
|
3467
|
+
console.log(` ${applied ? "✅ [APPLIED]" : " [skipped]"} ${cpn.id.padEnd(11)} (-$${cpn.discount})`);
|
|
3468
|
+
}
|
|
3469
|
+
}
|
|
3470
|
+
|
|
3471
|
+
optimizeCheckoutCart();
|
|
3472
|
+
```
|
|
3473
|
+
|
|
3474
|
+
---
|
|
3475
|
+
|
|
3476
|
+
# Portfolio Optimization (QP)
|
|
3477
|
+
|
|
3478
|
+
| Problem Class | Solver | Key APIs | Links |
|
|
3479
|
+
| :--- | :--- | :--- | :--- |
|
|
3480
|
+
| Convex Quadratic Programming (QP) | HiGHS (default) | `m.continuous()`, `m.constraint()`, `expr`, `minimize`, `dot`, `QuadExpr` | <ul><li><a href="https://ohtaman.github.io/washi/demo/?preset=portfolio_qp">Open in Playground</a></li><li><a href="https://github.com/ohtaman/washi/blob/main/examples/node/03-portfolio-qp.ts">Source Code</a></li></ul> |
|
|
3481
|
+
|
|
3482
|
+
The Markowitz mean-variance portfolio optimization problem is the foundation of modern quantitative finance: allocate capital across risky assets to minimize overall portfolio variance (risk) subject to achieving a minimum required expected return.
|
|
3483
|
+
|
|
3484
|
+
## Mathematical Formulation
|
|
3485
|
+
|
|
3486
|
+
Let $w_i \ge 0$ denote the allocation weight for asset $i$:
|
|
3487
|
+
|
|
3488
|
+
$$\begin{aligned}
|
|
3489
|
+
\text{Minimize} \quad & \mathbf{w}^T \boldsymbol{\Sigma} \mathbf{w} & (\text{Portfolio Variance}) \\
|
|
3490
|
+
\text{Subject to} \quad & \sum_{i=1}^n w_i = 1 & (\text{Full Capital Budget}) \\
|
|
3491
|
+
& \sum_{i=1}^n r_i w_i \ge R_{\text{target}} & (\text{Minimum Target Return}) \\
|
|
3492
|
+
& 0 \le w_i \le 1 & (\text{Long-Only Weights})
|
|
3493
|
+
\end{aligned}$$
|
|
3494
|
+
|
|
3495
|
+
where $\boldsymbol{\Sigma}$ is the positive semi-definite asset return covariance matrix.
|
|
3496
|
+
|
|
3497
|
+
## Implementation in WaSHI
|
|
3498
|
+
|
|
3499
|
+
```typescript
|
|
3500
|
+
import { Model, QuadExpr, sum, dot, minimize, expr } from "@ohtaman/washi";
|
|
3501
|
+
|
|
3502
|
+
async function solvePortfolio() {
|
|
3503
|
+
const m = new Model("PortfolioQP");
|
|
3504
|
+
|
|
3505
|
+
const assets = ["Tech", "Bonds", "GlobalEq", "RealEstate", "Gold"];
|
|
3506
|
+
const expectedReturns = [0.12, 0.04, 0.09, 0.07, 0.05];
|
|
3507
|
+
const targetReturn = 0.085; // 8.5% minimum expected return
|
|
3508
|
+
|
|
3509
|
+
// 5x5 Covariance Matrix
|
|
3510
|
+
const cov = [
|
|
3511
|
+
[0.040, 0.002, 0.025, 0.015, 0.005],
|
|
3512
|
+
[0.002, 0.005, 0.003, 0.002, 0.001],
|
|
3513
|
+
[0.025, 0.003, 0.030, 0.018, 0.008],
|
|
3514
|
+
[0.015, 0.002, 0.018, 0.025, 0.004],
|
|
3515
|
+
[0.005, 0.001, 0.008, 0.004, 0.020],
|
|
3516
|
+
];
|
|
3517
|
+
|
|
3518
|
+
// Allocation weights w_i in [0, 1]
|
|
3519
|
+
const w = m.continuous(assets, { lb: 0, ub: 1 });
|
|
3520
|
+
|
|
3521
|
+
// 1. Budget constraint: sum(w_i) == 1.0 (100%)
|
|
3522
|
+
m.constraint(expr`${sum(assets, (a) => w[a])} == 1.0`, "budget_limit");
|
|
3523
|
+
|
|
3524
|
+
// 2. Expected return constraint: sum(r_i * w_i) >= 8.5%
|
|
3525
|
+
const retMap = Object.fromEntries(assets.map((a, i) => [a, expectedReturns[i]]));
|
|
3526
|
+
m.constraint(expr`${dot(retMap, w)} >= ${targetReturn}`, "target_return");
|
|
3527
|
+
|
|
3528
|
+
// 3. Quadratic Objective: minimize risk w^T * Sigma * w
|
|
3529
|
+
let risk = new QuadExpr();
|
|
3530
|
+
for (let i = 0; i < assets.length; i++) {
|
|
3531
|
+
for (let j = 0; j < assets.length; j++) {
|
|
3532
|
+
if (cov[i][j] !== 0) {
|
|
3533
|
+
risk = risk.add(w[assets[i]].mul(w[assets[j]]).mul(cov[i][j]));
|
|
3534
|
+
}
|
|
3535
|
+
}
|
|
3536
|
+
}
|
|
3537
|
+
m.objective(minimize(risk));
|
|
3538
|
+
|
|
3539
|
+
// Solve natively with HiGHS Convex QP engine
|
|
3540
|
+
const status = await m.solve();
|
|
3541
|
+
|
|
3542
|
+
console.log(`Status: ${status}`);
|
|
3543
|
+
console.log(`Variance: ${m.objectiveValue?.toFixed(6)}`);
|
|
3544
|
+
console.log(`Volatility (Std Dev): ${((Math.sqrt(m.objectiveValue || 0)) * 100).toFixed(2)}%`);
|
|
3545
|
+
|
|
3546
|
+
assets.forEach((name) => {
|
|
3547
|
+
console.log(` ${name.padEnd(10)}: ${((w[name].value ?? 0) * 100).toFixed(2)}%`);
|
|
3548
|
+
});
|
|
3549
|
+
}
|
|
3550
|
+
|
|
3551
|
+
solvePortfolio();
|
|
3552
|
+
```
|
|
3553
|
+
|
|
3554
|
+
---
|
|
3555
|
+
|
|
3556
|
+
# SMT Security Policies
|
|
3557
|
+
|
|
3558
|
+
| Problem Class | Solver | Key APIs | Links |
|
|
3559
|
+
| :--- | :--- | :--- | :--- |
|
|
3560
|
+
| Propositional Logic & SMT | Z3 (`@ohtaman/washi/solvers/z3`) | `m.bool()`, `m.integer()`, `m.constraint()`, `expr`, `m.check()` | <ul><li><a href="https://ohtaman.github.io/washi/demo/?preset=smt_sat_scheduling">Open in Playground</a></li><li><a href="https://github.com/ohtaman/washi/blob/main/examples/node/04-smt-security-rules.ts">Source Code</a></li></ul> |
|
|
3561
|
+
|
|
3562
|
+
Satisfiability Modulo Theories (SMT) combines propositional logic (Boolean SAT) with first-order theories (linear integer arithmetic, real inequalities).
|
|
3563
|
+
|
|
3564
|
+
This example demonstrates how to formally verify and deduce system states in an IoT home security system using WaSHI and the Z3 solver engine.
|
|
3565
|
+
|
|
3566
|
+
## The Scenario
|
|
3567
|
+
|
|
3568
|
+
We model a security rule system with sensor readings and logic rules:
|
|
3569
|
+
1. When ambient light $\le 50$, it implies night time (`isNight`).
|
|
3570
|
+
2. When motion is detected at night and the alarm is armed, the siren must sound.
|
|
3571
|
+
3. If the alarm is armed, the front door must never be unlocked.
|
|
3572
|
+
4. When motion is detected at night, comfort lights should turn on.
|
|
3573
|
+
|
|
3574
|
+
## Implementation in WaSHI
|
|
3575
|
+
|
|
3576
|
+
```typescript
|
|
3577
|
+
import { Model, not, expr } from "@ohtaman/washi";
|
|
3578
|
+
import "@ohtaman/washi/solvers/z3";
|
|
3579
|
+
|
|
3580
|
+
async function verifySecurityRules() {
|
|
3581
|
+
const m = new Model({ name: "SmartHomeSecurity", solver: "z3" });
|
|
3582
|
+
|
|
3583
|
+
// 1. Boolean Propositional Variables
|
|
3584
|
+
const isNight = m.bool("isNight");
|
|
3585
|
+
const motionDetected = m.bool("motionDetected");
|
|
3586
|
+
const alarmArmed = m.bool("alarmArmed");
|
|
3587
|
+
const frontDoorUnlocked = m.bool("frontDoorUnlocked");
|
|
3588
|
+
const sirenActive = m.bool("sirenActive");
|
|
3589
|
+
const lightsOn = m.bool("lightsOn");
|
|
3590
|
+
|
|
3591
|
+
// 2. Arithmetic metric (lux sensor)
|
|
3592
|
+
const luxLevel = m.integer("luxLevel", { lb: 0, ub: 1000 });
|
|
3593
|
+
|
|
3594
|
+
// Rule 1: luxLevel <= 50 implies isNight
|
|
3595
|
+
m.constraint(luxLevel.le(50).implies(isNight), "dark_implies_night");
|
|
3596
|
+
|
|
3597
|
+
// Rule 2: (motionDetected AND isNight AND alarmArmed) => sirenActive
|
|
3598
|
+
m.constraint(
|
|
3599
|
+
motionDetected.and(isNight).and(alarmArmed).implies(sirenActive),
|
|
3600
|
+
"intrusion_rule"
|
|
3601
|
+
);
|
|
3602
|
+
|
|
3603
|
+
// Rule 3: alarmArmed => NOT(frontDoorUnlocked)
|
|
3604
|
+
m.constraint(alarmArmed.implies(not(frontDoorUnlocked)), "lock_safety_rule");
|
|
3605
|
+
|
|
3606
|
+
// Rule 4: (motionDetected AND isNight) => lightsOn
|
|
3607
|
+
m.constraint(motionDetected.and(isNight).implies(lightsOn), "comfort_lights");
|
|
3608
|
+
|
|
3609
|
+
// 3. Observed Context
|
|
3610
|
+
m.constraint(expr`${luxLevel} <= 20`, "sensor_reading_dark");
|
|
3611
|
+
m.constraint(alarmArmed, "user_armed_alarm");
|
|
3612
|
+
m.constraint(motionDetected, "sensor_motion_triggered");
|
|
3613
|
+
|
|
3614
|
+
// Check satisfiability and deduce state
|
|
3615
|
+
const res = await m.check({ solver: "z3" });
|
|
3616
|
+
|
|
3617
|
+
console.log(`Status: ${res.status}`);
|
|
3618
|
+
console.log(`Siren Active : ${sirenActive.value}`);
|
|
3619
|
+
console.log(`Lights On : ${lightsOn.value}`);
|
|
3620
|
+
console.log(`Door Locked : ${frontDoorUnlocked.value ? "No" : "Yes"}`);
|
|
3621
|
+
console.log(`Is Night : ${isNight.value}`);
|
|
3622
|
+
}
|
|
3623
|
+
|
|
3624
|
+
verifySecurityRules();
|
|
3625
|
+
```
|
|
3626
|
+
|
|
3627
|
+
---
|
|
3628
|
+
|
|
3629
|
+
# Conflict Diagnosis (Unsat Core)
|
|
3630
|
+
|
|
3631
|
+
| Problem Class | Solver | Key APIs | Links |
|
|
3632
|
+
| :--- | :--- | :--- | :--- |
|
|
3633
|
+
| Infeasibility Diagnosis / Unsat Core | Z3 (`@ohtaman/washi/solvers/z3`) | `m.integer()`, `m.continuous()`, `m.constraint()`, `expr`, `m.check()`, `m.conflicts` | <ul><li><a href="https://ohtaman.github.io/washi/demo/?preset=conflict_diagnosis_unsat">Open in Playground</a></li><li><a href="https://github.com/ohtaman/washi/blob/main/examples/node/05-unsat-core-diagnosis.ts">Source Code</a></li></ul> |
|
|
3634
|
+
|
|
3635
|
+
When business rules, budgets, and operational policies grow complex, constraints inevitably conflict. Traditional solvers simply report "Infeasible", leaving engineers to guess which of hundreds of rules caused the contradiction.
|
|
3636
|
+
|
|
3637
|
+
This example shows how to isolate the conflicting constraint subset using Z3 Unsat Core extraction.
|
|
3638
|
+
|
|
3639
|
+
## The Conflicting Scenario
|
|
3640
|
+
|
|
3641
|
+
A tech team is staffing a customer project:
|
|
3642
|
+
1. Each engineer costs $8,000 against the project budget (`budget_limit`).
|
|
3643
|
+
2. The customer requires rush delivery (`rush_required`).
|
|
3644
|
+
3. Rush delivery mandates at least 8 engineers (`rush_staffing_rule`).
|
|
3645
|
+
4. The board has imposed a strict budget cap of $50,000 (`strict_board_budget_cap`).
|
|
3646
|
+
5. Labor law requires at least 1 employee on staff (`minimum_one_employee`).
|
|
3647
|
+
|
|
3648
|
+
Notice that:
|
|
3649
|
+
|
|
3650
|
+
$$8 \text{ engineers} \times \$8,000 = \$64,000 > \$50,000 \text{ budget!}$$
|
|
3651
|
+
|
|
3652
|
+
## Implementation in WaSHI
|
|
3653
|
+
|
|
3654
|
+
```typescript
|
|
3655
|
+
import { Model, expr } from "@ohtaman/washi";
|
|
3656
|
+
import "@ohtaman/washi/solvers/z3";
|
|
3657
|
+
|
|
3658
|
+
async function diagnoseConflicts() {
|
|
3659
|
+
const m = new Model({ name: "ConflictDiagnosis", solver: "z3" });
|
|
3660
|
+
|
|
3661
|
+
const engineers = m.integer("engineers", { lb: 0, ub: 20 });
|
|
3662
|
+
const budget = m.continuous("budget", { lb: 0, ub: 100000 });
|
|
3663
|
+
const rushDelivery = m.bool("rushDelivery");
|
|
3664
|
+
|
|
3665
|
+
// Rule 1: Each engineer costs $8,000
|
|
3666
|
+
m.constraint(expr`8000 * ${engineers} <= ${budget}`, "budget_limit");
|
|
3667
|
+
|
|
3668
|
+
// Rule 2: Customer requires rush delivery
|
|
3669
|
+
m.constraint(rushDelivery, "rush_required");
|
|
3670
|
+
|
|
3671
|
+
// Rule 3: Rush delivery requires at least 8 engineers
|
|
3672
|
+
m.constraint(rushDelivery.implies(engineers.ge(8)), "rush_staffing_rule");
|
|
3673
|
+
|
|
3674
|
+
// Rule 4: Board budget cap of $50,000 (Conflict!)
|
|
3675
|
+
m.constraint(expr`${budget} <= 50000`, "strict_board_budget_cap");
|
|
3676
|
+
|
|
3677
|
+
// Rule 5: Innocent bystander rule
|
|
3678
|
+
m.constraint(expr`${engineers} >= 1`, "minimum_one_employee");
|
|
3679
|
+
|
|
3680
|
+
// Run diagnosis
|
|
3681
|
+
const res = await m.check({ solver: "z3" });
|
|
3682
|
+
|
|
3683
|
+
console.log(`Status: ${res.status}`);
|
|
3684
|
+
console.log("Conflicting Constraints (conflicts):", m.conflicts);
|
|
3685
|
+
}
|
|
3686
|
+
|
|
3687
|
+
diagnoseConflicts();
|
|
3688
|
+
```
|
|
3689
|
+
|
|
3690
|
+
---
|
|
3691
|
+
|
|
3692
|
+
# Capacitated Vehicle Routing Problem (CVRP, 50 Customers via Column Generation)
|
|
3693
|
+
|
|
3694
|
+
| Problem Class | Solver | Key APIs | Links |
|
|
3695
|
+
| :--- | :--- | :--- | :--- |
|
|
3696
|
+
| Combinatorial OR / Dantzig-Wolfe | HiGHS (default) | `m.solveColumnGeneration()`, `pricing`, `sum()`, `m.binary()`, `m.constraint()`, `expr` | <ul><li><a href="https://github.com/ohtaman/washi/blob/main/examples/node/13-vehicle-routing-column-generation.ts">Source Code</a></li><li><a href="../guide/decomposition.md">Decomposition Guide</a></li></ul> |
|
|
3697
|
+
|
|
3698
|
+
The **Capacitated Vehicle Routing Problem (CVRP)** determines the minimum number of delivery vehicles needed to visit a set of customers, subject to vehicle cargo capacity, maximum customer stops per shift, and route duration limits.
|
|
3699
|
+
|
|
3700
|
+
In a monolithic MIP formulation, enumerating all possible customer combinations and visit sequences leads to $O(2^n)$ binary variables. With **Column Generation (Dantzig-Wolfe Decomposition)**, routes are generated on demand:
|
|
3701
|
+
1. **Restricted Master Problem (RMP)**: Solves a set partitioning problem that selects a subset of candidate routes so that every customer is visited once.
|
|
3702
|
+
2. **Pricing Subproblem**: Uses the dual values (shadow prices) from the master LP relaxation to construct improving routes with negative reduced cost ($\bar{c} = 1 - \sum \pi_i < 0$) via cheapest insertion and 2-opt local search.
|
|
3703
|
+
|
|
3704
|
+
## Problem Inputs
|
|
3705
|
+
|
|
3706
|
+
We consider a distribution network with a central depot at $(0, 0)$ and **50 customer locations**:
|
|
3707
|
+
|
|
3708
|
+
- **Vehicle Cargo Capacity**: At most **10 units** per vehicle.
|
|
3709
|
+
- **Maximum Stops**: At most **4 customers** per route.
|
|
3710
|
+
- **Maximum Shift Duration**: At most **200 minutes** (transit time + service time) per route.
|
|
3711
|
+
- **Transit & Service Times**: Euclidean distances between nodes (1 unit = 1 minute) plus **10 minutes** of unloading service time per customer.
|
|
3712
|
+
|
|
3713
|
+
## Implementation in WaSHI
|
|
3714
|
+
|
|
3715
|
+
```typescript
|
|
3716
|
+
import { Model, expr, minimize, sum } from "@ohtaman/washi";
|
|
3717
|
+
|
|
3718
|
+
// --- Extracted Routing Helpers ---
|
|
3719
|
+
|
|
3720
|
+
// 2-opt local search to eliminate crossing paths in a tour
|
|
3721
|
+
function optimize2Opt(tour, dist) {
|
|
3722
|
+
let best = [...tour];
|
|
3723
|
+
let improved = true;
|
|
3724
|
+
|
|
3725
|
+
while (improved) {
|
|
3726
|
+
improved = false;
|
|
3727
|
+
for (let i = 1; i < best.length - 2; i++) {
|
|
3728
|
+
for (let j = i + 1; j < best.length - 1; j++) {
|
|
3729
|
+
const dCurrent = dist(best[i - 1], best[i]) + dist(best[j], best[j + 1]);
|
|
3730
|
+
const dSwap = dist(best[i - 1], best[j]) + dist(best[i], best[j + 1]);
|
|
3731
|
+
if (dSwap < dCurrent) {
|
|
3732
|
+
best = [...best.slice(0, i), ...best.slice(i, j + 1).reverse(), ...best.slice(j + 1)];
|
|
3733
|
+
improved = true;
|
|
3734
|
+
}
|
|
3735
|
+
}
|
|
3736
|
+
}
|
|
3737
|
+
}
|
|
3738
|
+
return best;
|
|
3739
|
+
}
|
|
3740
|
+
|
|
3741
|
+
// Inserts a customer into the tour at the position that minimizes detour distance
|
|
3742
|
+
function insertCheapest(tour, id, dist) {
|
|
3743
|
+
let bestPos = 1;
|
|
3744
|
+
let minCost = Infinity;
|
|
3745
|
+
|
|
3746
|
+
for (let p = 1; p < tour.length; p++) {
|
|
3747
|
+
const cost = dist(tour[p - 1], id) + dist(id, tour[p]) - dist(tour[p - 1], tour[p]);
|
|
3748
|
+
if (cost < minCost) {
|
|
3749
|
+
minCost = cost;
|
|
3750
|
+
bestPos = p;
|
|
3751
|
+
}
|
|
3752
|
+
}
|
|
3753
|
+
return [...tour.slice(0, bestPos), id, ...tour.slice(bestPos)];
|
|
3754
|
+
}
|
|
3755
|
+
|
|
3756
|
+
// Calculates transit time and total duration (transit + service) for a tour
|
|
3757
|
+
function calcRouteTime(tour, dist, serviceTimes) {
|
|
3758
|
+
let travelTime = 0;
|
|
3759
|
+
for (let i = 0; i < tour.length - 1; i++) {
|
|
3760
|
+
travelTime += dist(tour[i], tour[i + 1]);
|
|
3761
|
+
}
|
|
3762
|
+
const serviceTime = tour.slice(1, -1).reduce((acc, id) => acc + (serviceTimes[id] ?? 0), 0);
|
|
3763
|
+
return { travelTime, totalTime: travelTime + serviceTime };
|
|
3764
|
+
}
|
|
3765
|
+
|
|
3766
|
+
// Pricing subproblem: Finds an improving route with negative reduced cost (1 - dualSum < 0)
|
|
3767
|
+
function findImprovingRoute(candidates, config) {
|
|
3768
|
+
let bestRoute = null;
|
|
3769
|
+
let minReducedCost = -1e-4;
|
|
3770
|
+
|
|
3771
|
+
for (const seed of candidates.slice(0, 15)) {
|
|
3772
|
+
let tour = ["Depot", seed.id, "Depot"];
|
|
3773
|
+
let load = seed.demand;
|
|
3774
|
+
let dualSum = seed.dual;
|
|
3775
|
+
|
|
3776
|
+
for (const next of candidates) {
|
|
3777
|
+
if (tour.includes(next.id)) continue;
|
|
3778
|
+
if (load + next.demand > config.vehicleCapacity) continue;
|
|
3779
|
+
if (tour.length - 2 >= config.maxStops) continue;
|
|
3780
|
+
|
|
3781
|
+
const candidateTour = optimize2Opt(insertCheapest(tour, next.id, config.dist), config.dist);
|
|
3782
|
+
const { totalTime } = calcRouteTime(candidateTour, config.dist, config.serviceTimes);
|
|
3783
|
+
|
|
3784
|
+
if (totalTime <= config.maxRouteTime) {
|
|
3785
|
+
tour = candidateTour;
|
|
3786
|
+
load += next.demand;
|
|
3787
|
+
dualSum += next.dual;
|
|
3788
|
+
}
|
|
3789
|
+
}
|
|
3790
|
+
|
|
3791
|
+
const reducedCost = 1 - dualSum;
|
|
3792
|
+
if (reducedCost < minReducedCost) {
|
|
3793
|
+
minReducedCost = reducedCost;
|
|
3794
|
+
bestRoute = tour;
|
|
3795
|
+
}
|
|
3796
|
+
}
|
|
3797
|
+
return bestRoute;
|
|
3798
|
+
}
|
|
3799
|
+
|
|
3800
|
+
// --- Main CVRP via Column Generation ---
|
|
3801
|
+
|
|
3802
|
+
async function runCVRP() {
|
|
3803
|
+
const vehicleCapacity = 10;
|
|
3804
|
+
const maxStops = 4;
|
|
3805
|
+
const maxRouteTime = 200; // minutes
|
|
3806
|
+
|
|
3807
|
+
// Central depot and 50 customer locations
|
|
3808
|
+
const depot = { id: "Depot", x: 0, y: 0 };
|
|
3809
|
+
const customers = Array.from({ length: 50 }, (_, i) => {
|
|
3810
|
+
const angle = i * 2.4;
|
|
3811
|
+
const radius = 15 + (i % 5) * 8;
|
|
3812
|
+
return {
|
|
3813
|
+
id: `C${i + 1}`,
|
|
3814
|
+
x: Math.round(Math.cos(angle) * radius),
|
|
3815
|
+
y: Math.round(Math.sin(angle) * radius),
|
|
3816
|
+
demand: 1 + (i % 4),
|
|
3817
|
+
serviceTime: 10,
|
|
3818
|
+
};
|
|
3819
|
+
});
|
|
3820
|
+
|
|
3821
|
+
const nodes = Object.fromEntries([depot, ...customers].map((n) => [n.id, n]));
|
|
3822
|
+
const dist = (a, b) =>
|
|
3823
|
+
Math.round(Math.hypot(nodes[a].x - nodes[b].x, nodes[a].y - nodes[b].y));
|
|
3824
|
+
const serviceTimes = Object.fromEntries(customers.map((c) => [c.id, c.serviceTime]));
|
|
3825
|
+
|
|
3826
|
+
const routeConfig = { dist, serviceTimes, vehicleCapacity, maxStops, maxRouteTime };
|
|
3827
|
+
|
|
3828
|
+
// 1. Build Restricted Master Problem (RMP)
|
|
3829
|
+
const master = new Model("CVRP_FleetMaster");
|
|
3830
|
+
|
|
3831
|
+
// Initial naive routes: 1 dedicated vehicle per customer
|
|
3832
|
+
const initialRoutes = customers.map((c) => master.binary(`route_init_${c.id}`));
|
|
3833
|
+
master.objective(minimize(sum(initialRoutes)));
|
|
3834
|
+
|
|
3835
|
+
// Each customer must be visited by exactly 1 vehicle
|
|
3836
|
+
for (let i = 0; i < customers.length; i++) {
|
|
3837
|
+
master.constraint(expr`${initialRoutes[i]} == 1`, `visit_${customers[i].id}`);
|
|
3838
|
+
}
|
|
3839
|
+
const visitConstrs = master.constraints;
|
|
3840
|
+
|
|
3841
|
+
console.log(`Initial state: 50 naive single-customer routes (50 vehicles).`);
|
|
3842
|
+
console.log("Solving via Column Generation with dual-directed heuristic pricing...");
|
|
3843
|
+
|
|
3844
|
+
// 2. Solve via Column Generation
|
|
3845
|
+
const result = await master.solveColumnGeneration({
|
|
3846
|
+
finalizeAsMIP: true,
|
|
3847
|
+
pricing: async () => {
|
|
3848
|
+
// 1. Sort customers by shadow price (dual profit) descending
|
|
3849
|
+
const candidates = customers
|
|
3850
|
+
.map((c, i) => ({ id: c.id, demand: c.demand, dual: visitConstrs[i].dual ?? 0 }))
|
|
3851
|
+
.filter((c) => c.dual > 0)
|
|
3852
|
+
.sort((a, b) => b.dual - a.dual);
|
|
3853
|
+
|
|
3854
|
+
// 2. Solve pricing subproblem: find route with negative reduced cost (dualSum > 1)
|
|
3855
|
+
const bestRoute = findImprovingRoute(candidates, routeConfig);
|
|
3856
|
+
if (!bestRoute) return null; // No improving column found
|
|
3857
|
+
|
|
3858
|
+
// 3. Propose new column to Restricted Master Problem
|
|
3859
|
+
const { travelTime, totalTime } = calcRouteTime(bestRoute, dist, serviceTimes);
|
|
3860
|
+
const coefficients = {};
|
|
3861
|
+
for (const id of bestRoute.slice(1, -1)) {
|
|
3862
|
+
coefficients[`visit_${id}`] = 1;
|
|
3863
|
+
}
|
|
3864
|
+
|
|
3865
|
+
return {
|
|
3866
|
+
cost: 1,
|
|
3867
|
+
type: "Binary",
|
|
3868
|
+
coefficients,
|
|
3869
|
+
meta: { tour: bestRoute, travelTime, totalTime },
|
|
3870
|
+
};
|
|
3871
|
+
},
|
|
3872
|
+
});
|
|
3873
|
+
|
|
3874
|
+
console.log(`\nStatus: ${result.status}`);
|
|
3875
|
+
console.log(`Fleet Size: ${Math.round(result.objectiveValue ?? 0)} vehicles (heuristic column generation)`);
|
|
3876
|
+
console.log(`Total Columns Generated: ${result.columnsAdded}`);
|
|
3877
|
+
|
|
3878
|
+
console.log("\nSample routes selected in fleet:");
|
|
3879
|
+
const activeRoutes = result.generatedColumns.filter((c) => (c.variable.value ?? 0) > 0.5);
|
|
3880
|
+
for (const r of activeRoutes.slice(0, 5)) {
|
|
3881
|
+
const meta = r.proposal.meta;
|
|
3882
|
+
console.log(` ${meta?.tour.join(" -> ")} (Transit: ${meta?.travelTime}m, Total: ${meta?.totalTime}m)`);
|
|
3883
|
+
}
|
|
3884
|
+
}
|
|
3885
|
+
|
|
3886
|
+
runCVRP();
|
|
3887
|
+
```
|
|
3888
|
+
|
|
3889
|
+
## Notes
|
|
3890
|
+
|
|
3891
|
+
- **Master Problem and Pricing Separation**: The master problem solves the LP relaxation and final set-partitioning MIP, while the `pricing` callback searches for negative-reduced-cost routes using cheapest insertion and 2-opt local search.
|
|
3892
|
+
- **Dual Value Access**: Dual values from the master LP relaxation are read from `visitConstrs[i].dual` to prioritize customers with higher shadow prices during route construction.
|
|
3893
|
+
- **Route Metadata**: Each proposed column attaches `{ tour, travelTime, totalTime }` under `meta`, allowing the ordered stop sequence to be inspected from `result.generatedColumns` after the solve completes.
|
|
3894
|
+
|
|
3895
|
+
---
|
|
3896
|
+
|
|
3897
|
+
# Traveling Salesperson Problem (TSP via Benders Decomposition)
|
|
3898
|
+
|
|
3899
|
+
| Problem Class | Solver | Key APIs | Links |
|
|
3900
|
+
| :--- | :--- | :--- | :--- |
|
|
3901
|
+
| Combinatorial OR / Benders | HiGHS (default) | `m.solveBenders()`, `subproblem`, `m.binary()`, `m.constraint()`, `expr` | <ul><li><a href="https://github.com/ohtaman/washi/blob/main/examples/node/14-traveling-salesperson-benders.ts">Source Code</a></li><li><a href="../guide/decomposition.md">Decomposition Guide</a></li></ul> |
|
|
3902
|
+
|
|
3903
|
+
The **Traveling Salesperson Problem (TSP)** seeks the shortest closed tour that visits every city in a given set once and returns to the starting city.
|
|
3904
|
+
|
|
3905
|
+
In the **Dantzig-Fulkerson-Johnson (DFJ)** formulation, preventing disconnected cycles (**subtours**) requires subtour elimination constraints for every non-empty proper subset of cities $S \subset V$:
|
|
3906
|
+
|
|
3907
|
+
$$\sum_{i \in S, j \notin S} x_{ij} \ge 1 \quad \forall S \subset V, \; 2 \le |S| \le |V| - 1$$
|
|
3908
|
+
|
|
3909
|
+
Because there are $2^n - 2$ subsets, enumerating all subtour constraints upfront is impractical as $n$ grows. With **Benders Decomposition (Lazy Constraint Separation)**, constraints are added only when violated:
|
|
3910
|
+
1. **Master Problem**: Solves the degree-assignment relaxation (each city has 1 incoming and 1 outgoing edge).
|
|
3911
|
+
2. **Separation Subproblem**: Inspects the candidate solution graph. If disconnected cycles exist, it adds a **Benders Feasibility Cut** eliminating the smallest cycle and resolves until a single connected tour is obtained.
|
|
3912
|
+
|
|
3913
|
+
## Problem Inputs
|
|
3914
|
+
|
|
3915
|
+
We consider **5 cities** on a 2D Euclidean coordinate grid:
|
|
3916
|
+
|
|
3917
|
+
| City | Coordinates $(x, y)$ |
|
|
3918
|
+
| :--- | :---: |
|
|
3919
|
+
| **City A (0)** | $(0, 0)$ |
|
|
3920
|
+
| **City B (1)** | $(1, 0)$ |
|
|
3921
|
+
| **City C (2)** | $(1, 2)$ |
|
|
3922
|
+
| **City D (3)** | $(0, 2)$ |
|
|
3923
|
+
| **City E (4)** | $(0.5, 3)$ |
|
|
3924
|
+
|
|
3925
|
+
## Implementation in WaSHI
|
|
3926
|
+
|
|
3927
|
+
```typescript
|
|
3928
|
+
import { Model, expr, minimize, LinExpr } from "@ohtaman/washi";
|
|
3929
|
+
|
|
3930
|
+
async function runTSP() {
|
|
3931
|
+
const cities = [
|
|
3932
|
+
{ name: "A", x: 0, y: 0 },
|
|
3933
|
+
{ name: "B", x: 1, y: 0 },
|
|
3934
|
+
{ name: "C", x: 1, y: 2 },
|
|
3935
|
+
{ name: "D", x: 0, y: 2 },
|
|
3936
|
+
{ name: "E", x: 0.5, y: 3 },
|
|
3937
|
+
];
|
|
3938
|
+
const n = cities.length;
|
|
3939
|
+
|
|
3940
|
+
function dist(i, j) {
|
|
3941
|
+
const dx = cities[i].x - cities[j].x;
|
|
3942
|
+
const dy = cities[i].y - cities[j].y;
|
|
3943
|
+
return Math.hypot(dx, dy);
|
|
3944
|
+
}
|
|
3945
|
+
|
|
3946
|
+
// 1. Master Problem: Degree assignment relaxation
|
|
3947
|
+
const master = new Model("TSP_BendersMaster");
|
|
3948
|
+
const x = {};
|
|
3949
|
+
|
|
3950
|
+
for (let i = 0; i < n; i++) {
|
|
3951
|
+
x[i] = {};
|
|
3952
|
+
for (let j = 0; j < n; j++) {
|
|
3953
|
+
if (i !== j) {
|
|
3954
|
+
x[i][j] = master.binary(`x_${i}_${j}`);
|
|
3955
|
+
}
|
|
3956
|
+
}
|
|
3957
|
+
}
|
|
3958
|
+
|
|
3959
|
+
// Objective: Minimize total tour distance
|
|
3960
|
+
let totalDistance = new LinExpr();
|
|
3961
|
+
for (let i = 0; i < n; i++) {
|
|
3962
|
+
for (let j = 0; j < n; j++) {
|
|
3963
|
+
if (i !== j) {
|
|
3964
|
+
totalDistance = totalDistance.add(x[i][j].mul(dist(i, j)));
|
|
3965
|
+
}
|
|
3966
|
+
}
|
|
3967
|
+
}
|
|
3968
|
+
master.objective(minimize(totalDistance));
|
|
3969
|
+
|
|
3970
|
+
// Each city must have 1 outgoing and 1 incoming edge
|
|
3971
|
+
for (let i = 0; i < n; i++) {
|
|
3972
|
+
let outExpr = new LinExpr();
|
|
3973
|
+
let inExpr = new LinExpr();
|
|
3974
|
+
for (let j = 0; j < n; j++) {
|
|
3975
|
+
if (i !== j) {
|
|
3976
|
+
outExpr = outExpr.add(x[i][j]);
|
|
3977
|
+
inExpr = inExpr.add(x[j][i]);
|
|
3978
|
+
}
|
|
3979
|
+
}
|
|
3980
|
+
master.constraint(expr`${outExpr} == 1`, `out_${i}`);
|
|
3981
|
+
master.constraint(expr`${inExpr} == 1`, `in_${i}`);
|
|
3982
|
+
}
|
|
3983
|
+
|
|
3984
|
+
console.log("Solving TSP with Benders lazy subtour elimination...");
|
|
3985
|
+
|
|
3986
|
+
// 2. Solve via Benders Decomposition
|
|
3987
|
+
let cutCounter = 0;
|
|
3988
|
+
const result = await master.solveBenders({
|
|
3989
|
+
subproblem: () => {
|
|
3990
|
+
// Find active directed edges in the master solution
|
|
3991
|
+
const succ = {};
|
|
3992
|
+
for (let i = 0; i < n; i++) {
|
|
3993
|
+
for (let j = 0; j < n; j++) {
|
|
3994
|
+
if (i !== j && Math.round(x[i][j].value ?? 0) === 1) {
|
|
3995
|
+
succ[i] = j;
|
|
3996
|
+
}
|
|
3997
|
+
}
|
|
3998
|
+
}
|
|
3999
|
+
|
|
4000
|
+
// Trace connected cycles
|
|
4001
|
+
const visited = new Set();
|
|
4002
|
+
const cycles = [];
|
|
4003
|
+
for (let i = 0; i < n; i++) {
|
|
4004
|
+
if (!visited.has(i)) {
|
|
4005
|
+
const cycle = [];
|
|
4006
|
+
let curr = i;
|
|
4007
|
+
while (!visited.has(curr)) {
|
|
4008
|
+
visited.add(curr);
|
|
4009
|
+
cycle.push(curr);
|
|
4010
|
+
curr = succ[curr];
|
|
4011
|
+
}
|
|
4012
|
+
cycles.push(cycle);
|
|
4013
|
+
}
|
|
4014
|
+
}
|
|
4015
|
+
|
|
4016
|
+
// If more than 1 cycle, subtours exist: inject Benders feasibility cut
|
|
4017
|
+
if (cycles.length > 1) {
|
|
4018
|
+
const smallestCycle = cycles.reduce((min, c) => (c.length < min.length ? c : min));
|
|
4019
|
+
const S = new Set(smallestCycle);
|
|
4020
|
+
cutCounter++;
|
|
4021
|
+
console.log(`Subtour detected: [${smallestCycle.map(k => cities[k].name).join(" -> ")}]. Adding Benders cut #${cutCounter}.`);
|
|
4022
|
+
|
|
4023
|
+
let cutLhs = new LinExpr();
|
|
4024
|
+
for (const i of S) {
|
|
4025
|
+
for (let j = 0; j < n; j++) {
|
|
4026
|
+
if (!S.has(j)) {
|
|
4027
|
+
cutLhs = cutLhs.add(x[i][j]);
|
|
4028
|
+
}
|
|
4029
|
+
}
|
|
4030
|
+
}
|
|
4031
|
+
|
|
4032
|
+
return {
|
|
4033
|
+
kind: "feasibility",
|
|
4034
|
+
constraint: expr`${cutLhs} >= 1`,
|
|
4035
|
+
name: `subtour_cut_${cutCounter}`,
|
|
4036
|
+
};
|
|
4037
|
+
}
|
|
4038
|
+
|
|
4039
|
+
return null; // Single connected tour found
|
|
4040
|
+
},
|
|
4041
|
+
});
|
|
4042
|
+
|
|
4043
|
+
console.log(`\nStatus: ${result.status}`);
|
|
4044
|
+
console.log(`Optimal Tour Distance: ${result.objectiveValue?.toFixed(3)}`);
|
|
4045
|
+
console.log(`Total Benders Cuts Added: ${result.cutsAdded}`);
|
|
4046
|
+
console.log(`Iterations: ${result.iterations}`);
|
|
4047
|
+
|
|
4048
|
+
console.log("\nOptimal tour edges:");
|
|
4049
|
+
for (let i = 0; i < n; i++) {
|
|
4050
|
+
for (let j = 0; j < n; j++) {
|
|
4051
|
+
if (i !== j && (result.values[`x_${i}_${j}`] ?? 0) > 0.5) {
|
|
4052
|
+
console.log(` ${cities[i].name} -> ${cities[j].name} (distance: ${dist(i, j).toFixed(3)})`);
|
|
4053
|
+
}
|
|
4054
|
+
}
|
|
4055
|
+
}
|
|
4056
|
+
}
|
|
4057
|
+
|
|
4058
|
+
runTSP();
|
|
4059
|
+
```
|
|
4060
|
+
|
|
4061
|
+
## Notes
|
|
4062
|
+
|
|
4063
|
+
- **Lazy Subtour Elimination**: The master problem starts with only the $2n$ degree constraints, and subtour elimination cuts are added on demand when disconnected cycles appear in the master solution.
|
|
4064
|
+
- **Separation Callback**: The `subproblem` callback reads candidate edge values via `x[i][j].value`, traces connected components in $O(n)$, and returns `{ kind: "feasibility", constraint: ... }` when multiple cycles are present.
|
|
4065
|
+
- **Termination Condition**: Returning `null` from `subproblem` indicates that no violated subtour constraints remain.
|