@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.
Files changed (130) hide show
  1. package/README.ja.md +149 -121
  2. package/README.md +149 -121
  3. package/README.zh-CN.md +150 -122
  4. package/THIRD_PARTY_LICENSES.md +3 -4
  5. package/dist/browser/_washi-node-only-BvRk9kiK.js +0 -0
  6. package/dist/browser/browser-runtime-TLCY0hkc.js +1793 -0
  7. package/dist/browser/highs-worker.js +21 -3
  8. package/dist/browser/highs.wasm +0 -0
  9. package/dist/browser/highs.wasm.LICENSE +1 -2
  10. package/dist/browser/loader-DewlSho8.js +643 -0
  11. package/dist/browser/solvers/glpk.js +433 -286
  12. package/dist/browser/solvers/glpk.min.js +1 -1
  13. package/dist/browser/solvers/z3.js +1008 -981
  14. package/dist/browser/solvers/z3.min.js +2 -2
  15. package/dist/browser/washi.js +2778 -673
  16. package/dist/browser/washi.min.js +3 -4
  17. package/dist/lib/_washi-node-only-BvRk9kiK.js +0 -0
  18. package/dist/lib/highs-browser-runtime.js +2120 -4124
  19. package/dist/lib/highs-node-runtime.js +24 -1
  20. package/dist/lib/highs-runtime.js +2 -0
  21. package/dist/lib/highs-worker.js +21 -3
  22. package/dist/lib/highs.wasm +0 -0
  23. package/dist/lib/highs.wasm.LICENSE +1 -2
  24. package/dist/lib/loader-BZUQNvh4.js +649 -0
  25. package/dist/lib/registry-C20sxTwm.js +964 -0
  26. package/dist/lib/solvers/glpk.js +201 -90
  27. package/dist/lib/solvers/z3.js +399 -259
  28. package/dist/lib/washi.js +2363 -1056
  29. package/dist/types/decomposition/benders.d.ts +32 -0
  30. package/dist/types/decomposition/benders.d.ts.map +1 -0
  31. package/dist/types/decomposition/column-generation.d.ts +34 -0
  32. package/dist/types/decomposition/column-generation.d.ts.map +1 -0
  33. package/dist/types/decomposition/index.d.ts +4 -0
  34. package/dist/types/decomposition/index.d.ts.map +1 -0
  35. package/dist/types/decomposition/types.d.ts +230 -0
  36. package/dist/types/decomposition/types.d.ts.map +1 -0
  37. package/dist/types/index.d.ts +5 -4
  38. package/dist/types/index.d.ts.map +1 -1
  39. package/dist/types/modeler/big-m.d.ts +75 -0
  40. package/dist/types/modeler/big-m.d.ts.map +1 -0
  41. package/dist/types/modeler/boolean-expr.d.ts +103 -14
  42. package/dist/types/modeler/boolean-expr.d.ts.map +1 -1
  43. package/dist/types/modeler/constraint.d.ts +47 -9
  44. package/dist/types/modeler/constraint.d.ts.map +1 -1
  45. package/dist/types/modeler/expression.d.ts +123 -28
  46. package/dist/types/modeler/expression.d.ts.map +1 -1
  47. package/dist/types/modeler/index.d.ts +11 -6
  48. package/dist/types/modeler/index.d.ts.map +1 -1
  49. package/dist/types/modeler/linearizer.d.ts +15 -0
  50. package/dist/types/modeler/linearizer.d.ts.map +1 -0
  51. package/dist/types/modeler/lp-writer.d.ts +30 -3
  52. package/dist/types/modeler/lp-writer.d.ts.map +1 -1
  53. package/dist/types/modeler/model.d.ts +623 -131
  54. package/dist/types/modeler/model.d.ts.map +1 -1
  55. package/dist/types/modeler/objective.d.ts +59 -0
  56. package/dist/types/modeler/objective.d.ts.map +1 -0
  57. package/dist/types/modeler/parser.d.ts +34 -10
  58. package/dist/types/modeler/parser.d.ts.map +1 -1
  59. package/dist/types/modeler/quad-expr.d.ts +26 -7
  60. package/dist/types/modeler/quad-expr.d.ts.map +1 -1
  61. package/dist/types/modeler/solution-validator.d.ts +93 -0
  62. package/dist/types/modeler/solution-validator.d.ts.map +1 -0
  63. package/dist/types/modeler/solvers/glpk/solver.d.ts +26 -2
  64. package/dist/types/modeler/solvers/glpk/solver.d.ts.map +1 -1
  65. package/dist/types/modeler/solvers/highs/browser-runtime.d.ts +2 -1
  66. package/dist/types/modeler/solvers/highs/browser-runtime.d.ts.map +1 -1
  67. package/dist/types/modeler/solvers/highs/driver.d.ts +232 -0
  68. package/dist/types/modeler/solvers/highs/driver.d.ts.map +1 -0
  69. package/dist/types/modeler/solvers/highs/index.d.ts +1 -0
  70. package/dist/types/modeler/solvers/highs/index.d.ts.map +1 -1
  71. package/dist/types/modeler/solvers/highs/loader.d.ts +38 -13
  72. package/dist/types/modeler/solvers/highs/loader.d.ts.map +1 -1
  73. package/dist/types/modeler/solvers/highs/node-runtime.d.ts +2 -1
  74. package/dist/types/modeler/solvers/highs/node-runtime.d.ts.map +1 -1
  75. package/dist/types/modeler/solvers/highs/solver.d.ts +12 -5
  76. package/dist/types/modeler/solvers/highs/solver.d.ts.map +1 -1
  77. package/dist/types/modeler/solvers/highs/worker-client.d.ts.map +1 -1
  78. package/dist/types/modeler/solvers/mock.d.ts +14 -5
  79. package/dist/types/modeler/solvers/mock.d.ts.map +1 -1
  80. package/dist/types/modeler/solvers/types.d.ts +73 -11
  81. package/dist/types/modeler/solvers/types.d.ts.map +1 -1
  82. package/dist/types/modeler/solvers/z3/loader.d.ts +2 -3
  83. package/dist/types/modeler/solvers/z3/loader.d.ts.map +1 -1
  84. package/dist/types/modeler/solvers/z3/solver.d.ts +15 -5
  85. package/dist/types/modeler/solvers/z3/solver.d.ts.map +1 -1
  86. package/dist/types/modeler/types.d.ts +86 -1
  87. package/dist/types/modeler/types.d.ts.map +1 -1
  88. package/dist/types/modeler/validation.d.ts +56 -0
  89. package/dist/types/modeler/validation.d.ts.map +1 -0
  90. package/dist/types/modeler/variable.d.ts +48 -14
  91. package/dist/types/modeler/variable.d.ts.map +1 -1
  92. package/docs/examples/diet-sensitivity.md +90 -0
  93. package/docs/examples/ecommerce-coupons.md +106 -0
  94. package/docs/examples/index.md +57 -0
  95. package/docs/examples/knapsack.md +60 -0
  96. package/docs/examples/portfolio-qp.md +83 -0
  97. package/docs/examples/production.md +51 -0
  98. package/docs/examples/shift-scheduling.md +175 -0
  99. package/docs/examples/smt-security.md +74 -0
  100. package/docs/examples/transportation.md +90 -0
  101. package/docs/examples/traveling-salesperson.md +173 -0
  102. package/docs/examples/unsat-core.md +64 -0
  103. package/docs/examples/vehicle-routing.md +206 -0
  104. package/docs/getting-started/ai-agents.md +271 -0
  105. package/docs/getting-started/cross-origin.md +87 -0
  106. package/docs/getting-started/index.md +116 -0
  107. package/docs/getting-started/runtimes.md +227 -0
  108. package/docs/guide/constraints.md +177 -0
  109. package/docs/guide/decomposition.md +399 -0
  110. package/docs/guide/expressions.md +204 -0
  111. package/docs/guide/objectives.md +170 -0
  112. package/docs/guide/unsat-core.md +154 -0
  113. package/docs/guide/variables.md +188 -0
  114. package/docs/ja/getting-started/ai-agents.md +270 -0
  115. package/docs/solvers/custom-solver.md +525 -0
  116. package/docs/solvers/glpk.md +66 -0
  117. package/docs/solvers/highs.md +115 -0
  118. package/docs/solvers/index.md +42 -0
  119. package/docs/solvers/z3.md +78 -0
  120. package/docs/zh/getting-started/ai-agents.md +270 -0
  121. package/llms-full.txt +4065 -0
  122. package/llms.txt +201 -0
  123. package/package.json +32 -16
  124. package/skills/washi/SKILL.md +197 -0
  125. package/dist/browser/browser-runtime-D13gHOT5.js +0 -4189
  126. package/dist/browser/loader-CVt4CloI.js +0 -58
  127. package/dist/lib/async-mutex-Duo4jwzn.js +0 -195
  128. package/dist/lib/constraint-BRuB3ooC.js +0 -90
  129. package/dist/lib/loader-Dti0v9yC.js +0 -50
  130. 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.