@ohtaman/washi 0.6.0 → 0.7.0

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