@ohtaman/washi 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/README.ja.md +103 -386
  2. package/README.md +104 -387
  3. package/README.zh-CN.md +103 -386
  4. package/THIRD_PARTY_LICENSES.md +5 -4
  5. package/dist/browser/_washi-node-only-BvRk9kiK.js +0 -0
  6. package/dist/browser/browser-runtime-TLCY0hkc.js +1793 -0
  7. package/dist/browser/highs-worker.js +1 -1
  8. package/dist/browser/highs.wasm +0 -0
  9. package/dist/browser/highs.wasm.LICENSE +1 -2
  10. package/dist/browser/loader-DewlSho8.js +643 -0
  11. package/dist/browser/solvers/glpk.js +3 -3
  12. package/dist/browser/solvers/glpk.min.js +1 -1
  13. package/dist/browser/solvers/z3.js +7 -1
  14. package/dist/browser/solvers/z3.min.js +1 -1
  15. package/dist/browser/washi.js +1708 -540
  16. package/dist/browser/washi.min.js +3 -4
  17. package/dist/lib/_washi-node-only-BvRk9kiK.js +0 -0
  18. package/dist/lib/highs-browser-runtime.js +2120 -4124
  19. package/dist/lib/highs-node-runtime.js +24 -1
  20. package/dist/lib/highs-runtime.js +2 -0
  21. package/dist/lib/highs-worker.js +1 -1
  22. package/dist/lib/highs.wasm +0 -0
  23. package/dist/lib/highs.wasm.LICENSE +1 -2
  24. package/dist/lib/loader-BZUQNvh4.js +649 -0
  25. package/dist/lib/{registry-BEfvpZW3.js → registry-D7TZ3Wh_.js} +142 -59
  26. package/dist/lib/solvers/glpk.js +4 -4
  27. package/dist/lib/solvers/z3.js +1 -1
  28. package/dist/lib/washi.js +1576 -465
  29. package/dist/types/decomposition/benders.d.ts +32 -0
  30. package/dist/types/decomposition/benders.d.ts.map +1 -0
  31. package/dist/types/decomposition/column-generation.d.ts +34 -0
  32. package/dist/types/decomposition/column-generation.d.ts.map +1 -0
  33. package/dist/types/decomposition/index.d.ts +4 -0
  34. package/dist/types/decomposition/index.d.ts.map +1 -0
  35. package/dist/types/decomposition/types.d.ts +230 -0
  36. package/dist/types/decomposition/types.d.ts.map +1 -0
  37. package/dist/types/index.d.ts +4 -3
  38. package/dist/types/index.d.ts.map +1 -1
  39. package/dist/types/modeler/boolean-expr.d.ts.map +1 -1
  40. package/dist/types/modeler/constraint.d.ts +11 -0
  41. package/dist/types/modeler/constraint.d.ts.map +1 -1
  42. package/dist/types/modeler/expression.d.ts +57 -26
  43. package/dist/types/modeler/expression.d.ts.map +1 -1
  44. package/dist/types/modeler/index.d.ts +4 -3
  45. package/dist/types/modeler/index.d.ts.map +1 -1
  46. package/dist/types/modeler/linearizer.d.ts +15 -0
  47. package/dist/types/modeler/linearizer.d.ts.map +1 -0
  48. package/dist/types/modeler/lp-writer.d.ts +3 -1
  49. package/dist/types/modeler/lp-writer.d.ts.map +1 -1
  50. package/dist/types/modeler/model.d.ts +159 -8
  51. package/dist/types/modeler/model.d.ts.map +1 -1
  52. package/dist/types/modeler/parser.d.ts +25 -5
  53. package/dist/types/modeler/parser.d.ts.map +1 -1
  54. package/dist/types/modeler/solvers/glpk/solver.d.ts.map +1 -1
  55. package/dist/types/modeler/solvers/highs/browser-runtime.d.ts +2 -1
  56. package/dist/types/modeler/solvers/highs/browser-runtime.d.ts.map +1 -1
  57. package/dist/types/modeler/solvers/highs/driver.d.ts +232 -0
  58. package/dist/types/modeler/solvers/highs/driver.d.ts.map +1 -0
  59. package/dist/types/modeler/solvers/highs/index.d.ts +1 -0
  60. package/dist/types/modeler/solvers/highs/index.d.ts.map +1 -1
  61. package/dist/types/modeler/solvers/highs/loader.d.ts +4 -4
  62. package/dist/types/modeler/solvers/highs/loader.d.ts.map +1 -1
  63. package/dist/types/modeler/solvers/highs/node-runtime.d.ts +2 -1
  64. package/dist/types/modeler/solvers/highs/node-runtime.d.ts.map +1 -1
  65. package/dist/types/modeler/solvers/highs/solver.d.ts +1 -1
  66. package/dist/types/modeler/solvers/highs/solver.d.ts.map +1 -1
  67. package/dist/types/modeler/solvers/highs/worker-client.d.ts.map +1 -1
  68. package/dist/types/modeler/solvers/types.d.ts +4 -0
  69. package/dist/types/modeler/solvers/types.d.ts.map +1 -1
  70. package/dist/types/modeler/types.d.ts +27 -0
  71. package/dist/types/modeler/types.d.ts.map +1 -1
  72. package/docs/examples/diet-sensitivity.md +15 -14
  73. package/docs/examples/ecommerce-coupons.md +15 -22
  74. package/docs/examples/index.md +18 -12
  75. package/docs/examples/knapsack.md +8 -7
  76. package/docs/examples/portfolio-qp.md +11 -4
  77. package/docs/examples/production.md +4 -4
  78. package/docs/examples/shift-scheduling.md +20 -7
  79. package/docs/examples/smt-security.md +16 -4
  80. package/docs/examples/transportation.md +46 -19
  81. package/docs/examples/traveling-salesperson.md +157 -0
  82. package/docs/examples/unsat-core.md +15 -5
  83. package/docs/examples/vehicle-routing.md +208 -0
  84. package/docs/getting-started/ai-agents.md +33 -18
  85. package/docs/getting-started/cross-origin.md +56 -28
  86. package/docs/getting-started/index.md +40 -69
  87. package/docs/getting-started/runtimes.md +66 -61
  88. package/docs/guide/constraints.md +47 -41
  89. package/docs/guide/decomposition.md +267 -0
  90. package/docs/guide/expressions.md +90 -39
  91. package/docs/guide/formulation-tips.md +212 -0
  92. package/docs/guide/objectives.md +7 -4
  93. package/docs/guide/unsat-core.md +28 -9
  94. package/docs/guide/variables.md +33 -38
  95. package/docs/ja/getting-started/ai-agents.md +32 -18
  96. package/docs/solvers/custom-solver.md +1 -1
  97. package/docs/solvers/highs.md +48 -5
  98. package/docs/solvers/index.md +4 -4
  99. package/docs/solvers/z3.md +3 -0
  100. package/docs/zh/getting-started/ai-agents.md +32 -18
  101. package/llms-full.txt +1536 -478
  102. package/llms.txt +28 -22
  103. package/package.json +19 -16
  104. package/skills/washi/SKILL.md +21 -17
  105. package/dist/browser/browser-runtime-D13gHOT5.js +0 -4189
  106. package/dist/browser/loader-CtaDHef_.js +0 -273
  107. package/dist/lib/loader-DmNC_UNk.js +0 -279
package/llms-full.txt CHANGED
@@ -3,7 +3,10 @@
3
3
  > **Scope Note**: This document is the **consumer modeling quick-reference** for AI coding agents and developers building optimization or satisfiability models with `@ohtaman/washi`.
4
4
  > - **Portable Agent Skill (`SKILL.md`)**: For an actionable end-to-end modeling, `toLpString()` inspection, `validateSolution()` verification, and Z3 `unsatCore` conflict-diagnosis workflow skill (compatible with Claude Code, Antigravity, Cursor, Codex, and other `SKILL.md`-aware agents), use [`skills/washi/SKILL.md`](https://github.com/ohtaman/washi/blob/main/skills/washi/SKILL.md) (also shipped in the npm package at `node_modules/@ohtaman/washi/skills/washi/SKILL.md`):
5
5
  > ```bash
6
- > # Link or copy the packaged skill into your project's agent skills directory
6
+ > # Install directly via the skills CLI (skills.sh / vercel-labs/skills)
7
+ > npx skills add ohtaman/washi
8
+ >
9
+ > # Or link/copy the packaged skill into your project's agent skills directory
7
10
  > mkdir -p .agents/skills/washi
8
11
  > cp node_modules/@ohtaman/washi/skills/washi/SKILL.md .agents/skills/washi/SKILL.md
9
12
  > ```
@@ -54,7 +57,7 @@ import "@ohtaman/washi/solvers/glpk";
54
57
  1. **Bundled WASM by default (no implicit CDN fallback)**:
55
58
  - In Node.js and modern bundlers, WebAssembly assets resolve relative to the module automatically.
56
59
  - In browser deployments, deploy the full `dist/browser/` directory (JS, Worker, and `.wasm` files) together. If a local WASM URL is missing or returns an HTML SPA fallback, initialization fails immediately and reports the attempted URL—it **never** silently switches to a public CDN.
57
- - In browser runtimes, to load `highs.wasm` from a custom location, call `setHighsWasmUrl("https://.../highs.wasm")` or pass `await m.solve({ wasmUrl: "https://.../highs.wasm" })` (ignored in Node.js, which always loads 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).
58
61
  2. **Z3 requires a Secure Context, Cross-Origin Isolation, and CORS in browsers**:
59
62
  - Z3 WebAssembly uses `SharedArrayBuffer` multithreading. Browser pages running Z3 must run in a secure context (`window.isSecureContext === true`, i.e., HTTPS or `localhost`) and be cross-origin isolated (`window.crossOriginIsolated === true`) by serving:
60
63
  - `Cross-Origin-Opener-Policy: same-origin` (on the document)
@@ -63,7 +66,7 @@ import "@ohtaman/washi/solvers/glpk";
63
66
  - Importing `@ohtaman/washi` never auto-injects a Service Worker. When a secure context or cross-origin isolation is absent in a browser, Z3 rejects immediately with a descriptive error.
64
67
  3. **Web Workers & Cancellation (`AbortSignal`)**:
65
68
  - Pre-solve cancellation (`signal.aborted` before execution starts) is supported across all solvers.
66
- - In browsers where `Worker` is available, HiGHS runs in a dedicated Web Worker by default (`useWorker: true`). Passing `{ signal }` to `m.solve({ signal })` preemptively terminates an in-flight Worker solve (or cancels queued Worker requests).
69
+ - In browsers where `Worker` is available on a non-opaque effective origin (`window.origin !== "null"`), HiGHS runs in a dedicated Web Worker by default (`useWorker: true`). On opaque effective origins (`window.origin === "null"`, such as `file:` URLs or `sandbox="allow-scripts"` `<iframe>` embeds without `allow-same-origin`), HiGHS defaults to main-thread execution (`useWorker: false`). Passing `{ signal }` to `m.solve({ signal })` preemptively terminates an in-flight Worker solve (or cancels queued Worker requests).
67
70
  - On main-thread HiGHS (`useWorker: false` or in Node.js), cooperative mid-solve cancellation is supported when `abort()` is triggered synchronously inside an `onProgress` callback at a solver interrupt checkpoint; arbitrary asynchronous aborts from outside cannot preempt a blocked synchronous WASM thread until a callback checkpoint or completion. Single-threaded WASM solvers without interrupt hooks (GLPK, Z3) check `signal` before execution starts.
68
71
 
69
72
  ---
@@ -79,6 +82,7 @@ const m = new Model("ExampleModel");
79
82
 
80
83
  // Scalar variables
81
84
  const x = m.continuous("x", { lb: 0, ub: 100 });
85
+ const y = m.continuous("y", { lb: 0, ub: 100 });
82
86
  const n = m.integer("n", { lb: 0, ub: 10 });
83
87
  const b = m.binary("b");
84
88
  const flag = m.bool("flag"); // Propositional boolean (for Z3 / logical constraints)
@@ -100,12 +104,12 @@ const mixed = m.binary(["A", "B"] as const, 3); // Record<"A" | "B", Var[]>
100
104
  ### Step 2: Build Expressions (`expr`, `sum`, `dot`)
101
105
 
102
106
  - **Unified `expr` template tag**:
103
- - Linear expression (`LinExpr`): `` expr`30 * ${x} + 50 * ${n} - 10` ``
104
- - Quadratic expression (`QuadExpr`): `` expr`${x}^2 + 2 * ${x} * ${n} + ${n}^2` `` (variable-by-variable products and squares are supported for convex QP objectives).
105
- - Linear constraint (`Constraint`): `` expr`${x} + 2 * ${n} <= 40` `` (supports `<=`, `>=`, `==`, and `=`).
106
- - **Aggregations**:
107
- - `sum(iterable, mapper?)` sums an `Iterable` of `Var` / `LinExpr` / `number` (e.g., `Var[]`, `grid.flat()`, or `Object.values(record)`).
108
- - `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.
109
113
  - **Fluent methods**:
110
114
  - Arithmetic (on `LinExpr` and `QuadExpr`; `Var` supports `.add()`, `.sub()`, `.mul()`, `.neg()`): `.add(other)`, `.sub(other)`, `.mul(other)`, `.div(scalar)`, `.neg()`
111
115
  - Comparisons (on `Var` and `LinExpr`): `.le(rhs, name?)`, `.ge(rhs, name?)`, `.eq(rhs, name?)`
@@ -119,8 +123,8 @@ Constructing an `expr` or `.le()` object does **not** attach it to the model unt
119
123
  // Set objective using maximize() or minimize()
120
124
  m.objective(maximize(expr`30 * ${x} + 50 * ${n}`));
121
125
 
122
- // Register constraints on the model
123
- m.constraint(expr`${x} + 2 * ${n} <= 40`, "resource_limit");
126
+ // Register constraints on the model (returns the registered Constraint / BoolExpr or array)
127
+ const limit = m.constraint(expr`${x} + 2 * ${n} <= 40`, "resource_limit");
124
128
 
125
129
  // Indicator constraint (MIP Big-M reformulation: b === 1 => x <= 50)
126
130
  m.indicator(b, expr`${x} <= 50`, { activeValue: 1, name: "cap_when_b" });
@@ -132,9 +136,10 @@ m.indicator(b, expr`${x} <= 50`, { activeValue: 1, name: "cap_when_b" });
132
136
  - Returns `SolverStatus` (`"Optimal"`, `"Feasible"`, `"Infeasible"`, `"Unbounded"`, `"TimeLimit"`, `"IterationLimit"`, `"Error"`, `"Unknown"`, `"NotSolved"`, `"Sat"`, or `"Unsat"`). Built-in optimization solvers return the optimization statuses (`"Optimal"`, `"Feasible"`, `"Infeasible"`, `"Unbounded"`, `"TimeLimit"`, `"IterationLimit"`, `"Error"`, `"Unknown"`, `"NotSolved"`), where a time-, iteration-, or solution-limited run (or an interrupted solve) that found a valid primal incumbent returns `"Feasible"`; `"Sat"` and `"Unsat"` are included in `SolverStatus` for satisfiability/custom solver adapters (such as `MockSolver`).
133
137
  - Read values via `x.value`, `expr`...`.value`, `m.getValues(x)`, `m.getValues(container)`, `m.objectiveValue`, `constr.dual`, `constr.slack`, and `x.reducedCost`.
134
138
  - Call `m.validateSolution()` after solving to independently verify variable finiteness, variable bounds (`lb`/`ub`), integer/binary/boolean domains, linear constraint residuals, and logical constraints (`SolutionValidationReport`: `{ valid, complete, maxViolation, violations, ... }`). Note that `validateSolution()` checks primal feasibility of variables and constraints; it does not recompute or compare `m.objectiveValue`.
135
- - **Satisfiability & Unsat Core (`await m.check(options?)`)**:
136
- - Returns `CheckResult` (`{ status: "Sat" | "Unsat" | "Unknown", values: Record<string, number | boolean>, conflicts?: readonly string[], unsatCore?: readonly string[], runTime?: number }`), where `conflicts` and `unsatCore` are optional and populated only when an unsat-core-capable solver (such as Z3) returns `"Unsat"`.
137
- - For models containing only linear arithmetic constraints, `m.check()` works with any solver (including the default HiGHS and GLPK) by falling back to a feasibility `solve()`. Logical constraints (`BoolVar`, `and`, `or`, `implies`, etc.) and unsatisfiable-core extraction (`result.conflicts` / `result.unsatCore`) require an SMT-capable solver (`{ solver: "z3" }`). Because Z3 asserts variable bounds (`lb`/`ub`) and binary `{0, 1}` domains directly while tracking registered constraints, `conflicts` / `unsatCore` reports a subset of registered constraint names that conflicts *together with the model's fixed variable bounds/domains* (and is not guaranteed minimal).
139
+ - **Satisfiability & Conflict Diagnosis (`await m.check(options?)`)**:
140
+ - Returns `CheckResult` (`{ status: "Sat" | "Unsat" | "Unknown", values: Record<string, number | boolean>, conflicts?: readonly string[], unsatCore?: readonly string[], runTime?: number }`), where `conflicts` is populated when the model is proven `"Unsat"`.
141
+ - For models containing linear arithmetic constraints or logical constraints (`BoolVar`, `and`, `or`, `implies`, `xor`), `m.check()` works with any solver (including the default HiGHS and GLPK via single-solve elastic slack relaxation, automatically linearizing logical constraints into 0-1 MIP formulation) and diagnoses conflicting constraints as `conflicts` (leaving `unsatCore: undefined` for semantic rigor).
142
+ - Exact SMT unsatisfiable-core extraction (`result.unsatCore` and `result.conflicts`) is provided when using an SMT-capable solver (`{ solver: "z3" }`). Because Z3 asserts variable bounds (`lb`/`ub`) and binary `{0, 1}` domains directly while tracking registered constraints, `unsatCore` reports a subset of registered constraint names that conflicts *together with the model's fixed variable bounds/domains* (and is not guaranteed minimal).
138
143
  - **No concurrent mutation during async solve/check**:
139
144
  - Mutating variables, constraints, or the objective while `await m.solve()` or `await m.check()` is pending invalidates the in-flight execution and rejects the stale promise.
140
145
 
@@ -157,10 +162,13 @@ export async function runMipExample() {
157
162
  const qty = m.integer(products, { lb: 0, ub: 100 });
158
163
  const setup = m.binary("setup");
159
164
 
160
- m.objective(maximize(expr`${dot(profit, qty)} - 20 * ${setup}`));
161
- m.constraint(expr`${dot(woodPerUnit, qty)} <= 40`, "wood_cap");
162
- m.constraint(expr`${dot(laborPerUnit, qty)} <= 50`, "labor_cap");
163
- 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
+ });
164
172
 
165
173
  const status = await m.solve();
166
174
  const report = m.validateSolution();
@@ -265,24 +273,46 @@ export function verifyNegativePatterns() {
265
273
  }
266
274
  ```
267
275
 
276
+ ### Practical OR Modeling Guardrails
277
+
278
+ - **Prefer inequalities (`>=` / `<=`) over exact equalities (`==`)** for demand coverage and resource limits when objective costs are positive; reserve `==` for physical conservation laws (e.g., flow balance, portfolio weights summing to `1`).
279
+ - **Always provide finite `lb` and `ub` bounds** on variables referenced inside `m.indicator(...)` and omit `M` so WaSHI automatically derives the tightest valid Big-M instead of falling back to `1e6`.
280
+ - **Use soft constraints (elastic slack variables)** for non-rigid business targets (e.g., preferred staffing, safety stock, day-off requests) by adding a non-negative slack variable (`lb: 0`) with a tiered objective penalty, reserving hard constraints for physical or legal invariants.
281
+
268
282
  ---
269
283
 
270
284
  # Introduction & Installation
271
285
 
272
- 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**).
287
+
288
+ ```typescript
289
+ import { Model, expr, maximize } from "@ohtaman/washi";
290
+
291
+ const m = new Model("production");
273
292
 
274
- 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.
293
+ // 1. Define decision variables with realistic finite bounds (lb / ub)
294
+ const desks = m.integer("desks", { lb: 0, ub: 100 });
295
+ const chairs = m.integer("chairs", { lb: 0, ub: 100 });
275
296
 
276
- ### Why TypeScript & WebAssembly?
297
+ // 2. Set the objective function and constraints
298
+ m.objective(maximize(expr`30 * ${desks} + 50 * ${chairs}`));
299
+ m.constraint(expr`${desks} + 2 * ${chairs} <= 40`, "wood_capacity");
300
+ m.constraint(expr`2 * ${desks} + ${chairs} <= 50`, "labor_capacity");
277
301
 
278
- - **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.
279
- - **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]}`).
302
+ // 3. Solve (built-in HiGHS WebAssembly) and validate the solution
303
+ const status = await m.solve();
304
+ if (m.hasPrimalSolution) {
305
+ const report = m.validateSolution();
306
+ console.log(status, report.valid); // "Optimal", true
307
+ console.log(`Profit: ${m.objectiveValue} (desks=${desks.value}, chairs=${chairs.value})`); // 1100 (desks=20, chairs=10)
308
+ }
309
+ ```
280
310
 
281
311
  ---
282
312
 
283
313
  ## 📦 Installation
284
314
 
285
- Install WaSHI using your preferred package manager:
315
+ The default **HiGHS** solver (LP / MIP / Convex QP) is bundled and works out of the box:
286
316
 
287
317
  ```bash [npm]
288
318
  npm install @ohtaman/washi
@@ -300,96 +330,54 @@ yarn add @ohtaman/washi
300
330
  bun add @ohtaman/washi
301
331
  ```
302
332
 
303
- ### Optional Solver Dependencies
304
-
305
- WaSHI ships with **HiGHS** built-in with zero configuration. If you need SMT logic / Unsat Core diagnosis via **Z3**, or compatibility with **GLPK**, install their respective peer dependencies:
333
+ Install optional peer dependencies only if you need **Z3** (SMT logic & Unsat Core diagnosis) or **GLPK**:
306
334
 
307
335
  ```bash
308
- # For SMT logic and Unsat Core conflict diagnosis
309
- npm install z3-solver
310
-
311
- # For GLPK solver (GPL-3.0)
312
- npm install glpk.js
336
+ npm install z3-solver # For SMT logic and Unsat Core conflict diagnosis
337
+ npm install glpk.js # For GLPK solver (GPL-3.0)
313
338
  ```
314
339
 
315
340
  ---
316
341
 
317
- ## 🚀 Quickstart Walkthrough
318
-
319
- Here is a step-by-step example of solving a production allocation problem.
320
-
321
- ### 1. Import the Core Classes
322
-
323
- ```typescript
324
- import { Model, expr, maximize } from "@ohtaman/washi";
325
- ```
326
-
327
- ### 2. Instantiate a Model
328
-
329
- ```typescript
330
- const model = new Model("WoodworkingProfit");
331
- ```
332
-
333
- ### 3. Add Decision Variables
334
-
335
- Variables represent the unknowns you want to determine. Factory methods (`m.continuous`, `m.integer`, `m.binary`) clearly define their domain:
342
+ ## 💡 Why TypeScript & WebAssembly?
336
343
 
337
- ```typescript
338
- const x = model.integer("x", { lb: 0 });
339
- const y = model.integer("y", { lb: 0 });
340
- ```
341
-
342
- ### 4. Set the Objective Function
343
-
344
- Use the `expr` tagged template literal along with `maximize(...)` or `minimize(...)` to define the goal:
345
-
346
- ```typescript
347
- // Maximize 30*x + 50*y
348
- model.objective(maximize(expr`30 * ${x} + 50 * ${y}`));
349
- ```
344
+ - **Zero native setup across Node.js and the browser**: No platform-specific C/C++ builds or separate Python runtimes to manage. A single `npm install` runs the same modeling code across **Node.js APIs, CLIs, and scripts** as well as in the **browser**.
345
+ - **Lightweight embedding with standard Web APIs & UIs**: Go beyond local analysis scripts by shipping **interactive Web tools, mini-games, or Chrome extensions** that anyone can open via a URL with zero backend hosting cost. You can wire mathematical optimization directly into drag-and-drop UIs or **standard browser capabilities**—such as screen/camera capture and speech recognition (Web Speech API)—with no network round-trips or external data transfer.
346
+ - **End-to-end type safety from app data to model**: Prevent common indexing and dictionary-key mistakes with TypeScript's type system. Typed variable dictionaries (`Record<K, Var>`) and variable-reference interpolation (`` expr`30 * ${qty.desk} <= 40` ``) connect directly to your application schemas, preserving compile-time variable/key checks, IDE autocompletion, and symbol renaming.
347
+ - **Strong synergy with AI development & LLM workflows**: Keeping your application and optimization model in a **single TypeScript codebase** lets you validate everything in one test runner—without crossing a Python service boundary—and deploy easily as static assets or a single service. That tight test-and-deploy loop fits AI-assisted coding naturally, while at runtime you can pair flexible LLM extraction with exact solver feasibility and conflict diagnosis (`unsatCore`) in the same runtime.
350
348
 
351
- ### 5. Add Constraints
352
-
353
- Use `model.constraint()` with the `expr` tagged template to formulate equality and inequality constraints:
354
-
355
- ```typescript
356
- model.constraint(expr`${x} + 2 * ${y} <= 40`, "wood_capacity");
357
- model.constraint(expr`2 * ${x} + ${y} <= 50`, "labor_capacity");
358
- ```
359
-
360
- ### 6. Solve and Extract Results
349
+ ---
361
350
 
362
- Solve the model asynchronously via WebAssembly (HiGHS by default):
351
+ ## 🔍 Core Workflow Essentials
363
352
 
364
- ```typescript
365
- const status = await model.solve();
353
+ Three practical habits cover the essentials shown in the snippet above:
366
354
 
367
- console.log("Status:", status); // "Optimal"
368
- console.log("Optimal Objective Value:", model.objectiveValue); // 1100
369
- console.log(`Solution: x = ${x.value}, y = ${y.value}`); // x = 20, y = 10
370
- ```
355
+ 1. **Set realistic finite bounds (`lb`, `ub`)**: When defining variables with `m.continuous`, `m.integer`, or `m.binary`, finite bounds strengthen MIP presolve and enable automatic tight Big-M derivation for logical constraints.
356
+ 2. **Formulate objectives and constraints with `expr`**: Use `` m.objective(maximize(expr`...`)) `` and `` m.constraint(expr`... <= ...`, "name") ``. Naming constraints lets you pinpoint conflicting rules immediately if a model becomes infeasible.
357
+ 3. **Check `hasPrimalSolution` and call `validateSolution()`**: After `await m.solve()`, check `m.hasPrimalSolution` before reading `.value` on variables, and call `m.validateSolution()` to independently verify that bounds, integrality, and constraints hold within tolerance.
371
358
 
372
359
  ---
373
360
 
374
361
  ## 🧭 Next Steps
375
362
 
376
- - Give AI coding agents a concise API & deployment reference with the [AI Coding Agent Quick-Reference Guide](docs/getting-started/ai-agents.md).
377
- - Learn how to run WaSHI across different environments in [Runtime Environments](docs/getting-started/runtimes.md).
378
- - Understand how to formulate complex expressions in the [Modeling Guide](docs/guide/expressions.md).
379
- - Explore built-in and pluggable solvers in the [Solvers Matrix](docs/solvers/index.md).
380
- - Browse 9 runnable optimization and SMT recipes in [Examples & API Finder](docs/examples/index.md).
363
+ - **[Modeling Guide](docs/guide/variables.md)**: Array/dictionary variables, aggregations (`sum`, `dot`), logical constraints, and Unsat Core diagnosis
364
+ - **[Examples & API Finder](docs/examples/index.md)**: 11 runnable optimization and SMT recipes—from shift scheduling and portfolio QP to vehicle routing
365
+ - **[Solvers Overview](docs/solvers/index.md)**: Compare built-in HiGHS with optional Z3 and GLPK adapters
366
+ - **[Runtime Environments](docs/getting-started/runtimes.md)**: Setup details for Node.js, Vite / Next.js bundlers, CDNs, and static hosting
381
367
 
382
368
  ---
383
369
 
384
370
  # Runtime Environments & Deployment
385
371
 
386
- WaSHI runs across JavaScript and TypeScript runtimes—from Node.js, Bun, and Deno to modern bundlers, vanilla browser `<script>` tags, 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.
387
373
 
388
374
  ---
389
375
 
390
- ## 🟢 Node.js
376
+ ## 🟢 Node.js, Bun & Deno
391
377
 
392
- 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.
378
+ ### Node.js
379
+
380
+ WaSHI supports Node.js (v18+) out of the box with standard ECMAScript Modules (ESM). The built-in HiGHS WebAssembly binary is loaded directly from the installed package:
393
381
 
394
382
  ```typescript
395
383
  import { Model, expr, maximize } from "@ohtaman/washi";
@@ -402,8 +390,10 @@ m.objective(maximize(expr`2 * ${x} + 3 * ${y}`));
402
390
  m.constraint(expr`${x} + ${y} <= 15`, "capacity");
403
391
 
404
392
  await m.solve();
405
- console.log(`Status: ${m.status}, Objective: ${m.objectiveValue}`);
406
- 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
+ }
407
397
  ```
408
398
 
409
399
  Run directly with `tsx` or `node`:
@@ -412,11 +402,33 @@ Run directly with `tsx` or `node`:
412
402
  npx tsx app.ts
413
403
  ```
414
404
 
405
+ ### Bun & Deno
406
+
407
+ ```bash
408
+ # Bun
409
+ bun add @ohtaman/washi
410
+ bun run app.ts
411
+ ```
412
+
413
+ In Deno (v1.30+ with `npm:` specifiers):
414
+
415
+ ```typescript
416
+ import { Model, maximize } from "npm:@ohtaman/washi";
417
+
418
+ const m = new Model();
419
+ const x = m.continuous("x", { lb: 0, ub: 10 });
420
+ m.objective(maximize(x));
421
+ await m.solve();
422
+ if (m.hasPrimalSolution) {
423
+ console.log("x =", x.value);
424
+ }
425
+ ```
426
+
415
427
  ---
416
428
 
417
- ## 🌐 Bundlers (Vite, Webpack, Rollup, Next.js)
429
+ ## 🌐 Bundlers & Static Hosting (Vite, Next.js, Webpack, Rollup)
418
430
 
419
- ### Modern Bundlers (Vite / Rollup / Webpack)
431
+ ### Modern Bundlers & Static Deployment
420
432
 
421
433
  When importing `@ohtaman/washi` in a browser bundler, the `browser` export condition resolves automatically:
422
434
 
@@ -428,14 +440,16 @@ export async function runOptimization() {
428
440
  const x = m.continuous("x", { lb: 0, ub: 100 });
429
441
  m.objective(maximize(x));
430
442
  await m.solve();
431
- return x.value;
443
+ return m.hasPrimalSolution ? x.value : null;
432
444
  }
433
445
  ```
434
446
 
447
+ Because both the modeler and solver run on WebAssembly, the static output of `vite build` (or your bundler's production build) can be deployed directly to **GitHub Pages, Cloudflare Pages, Vercel, or Netlify** with no backend server required. (The default HiGHS and GLPK solvers require no special HTTP headers; if you use Z3 in the browser, see [Cross-Origin Isolation](docs/getting-started/cross-origin.md).)
448
+
435
449
  #### Background Web Worker Execution (Default)
436
- In browsers, HiGHS automatically runs inside a dedicated **Web Worker** so 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`).
437
451
 
438
- 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`:
439
453
 
440
454
  ```typescript
441
455
  await m.solve({ useWorker: false });
@@ -474,7 +488,9 @@ export default function OptimizerComponent() {
474
488
  const x = m.continuous("x", { lb: 0, ub: 50 });
475
489
  m.objective(maximize(x));
476
490
  await m.solve();
477
- setVal(x.value);
491
+ if (m.hasPrimalSolution) {
492
+ setVal(x.value);
493
+ }
478
494
  };
479
495
 
480
496
  return (
@@ -490,36 +506,36 @@ export default function OptimizerComponent() {
490
506
 
491
507
  ## 📦 Browser ESM (`<script type="module">`)
492
508
 
493
- 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:
494
510
 
495
511
  ```html
496
512
  <script type="module">
497
- // Serve from your local static server / dist:
498
- import { Model, expr, maximize } from "./dist/browser/washi.js";
499
- // Or from an npm CDN once a compatible release is published:
500
- // import { Model, expr, maximize } from "https://cdn.jsdelivr.net/npm/@ohtaman/washi@<version>/dist/browser/washi.js";
513
+ // From an npm CDN (or "./dist/browser/washi.js" when self-hosted):
514
+ import { Model, expr, maximize } from "https://cdn.jsdelivr.net/npm/@ohtaman/washi@0.6/dist/browser/washi.js";
501
515
  // Optional solver plugins:
502
- // import "./dist/browser/solvers/glpk.js";
503
- // 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";
504
518
 
505
519
  const m = new Model("BrowserESM");
506
- const x = m.integer("x", { lb: 0 });
507
- const y = m.integer("y", { lb: 0 });
520
+ const x = m.integer("x", { lb: 0, ub: 100 });
521
+ const y = m.integer("y", { lb: 0, ub: 100 });
508
522
 
509
523
  m.objective(maximize(expr`30 * ${x} + 50 * ${y}`));
510
524
  m.constraint(expr`${x} + 2 * ${y} <= 40`, "wood");
511
525
  m.constraint(expr`2 * ${x} + ${y} <= 50`, "labor");
512
526
 
513
527
  await m.solve();
514
- console.log("Optimal Objective:", m.objectiveValue);
528
+ if (m.hasPrimalSolution) {
529
+ console.log("Optimal Objective:", m.objectiveValue);
530
+ }
515
531
  </script>
516
532
  ```
517
533
 
518
534
  ---
519
535
 
520
- ## 🟨 Classic `<script>` Tags (IIFE / Standalone Build / CDN)
536
+ ## 🟨 Classic `<script>` Tags (IIFE / Standalone HTML / CDN)
521
537
 
522
- 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:
523
539
 
524
540
  ```html
525
541
  <!DOCTYPE html>
@@ -532,13 +548,11 @@ For static HTML pages, CodePen, or JSFiddle, load `washi.min.js` via a standard
532
548
  <h1>WaSHI Browser Optimization</h1>
533
549
  <pre id="output">Solving...</pre>
534
550
 
535
- <!-- Core WaSHI + HiGHS (MIT) from local static build / assets -->
536
- <script src="./dist/browser/washi.min.js"></script>
537
- <!-- Or via CDN once a compatible release is published: -->
538
- <!-- <script src="https://cdn.jsdelivr.net/npm/@ohtaman/washi@<version>/dist/browser/washi.min.js"></script> -->
551
+ <!-- Core WaSHI + HiGHS (MIT) via CDN (or "./dist/browser/washi.min.js" when self-hosted) -->
552
+ <script src="https://cdn.jsdelivr.net/npm/@ohtaman/washi@0.6/dist/browser/washi.min.js"></script>
539
553
  <!-- Optional plugins (auto-register on window.Washi): -->
540
- <!-- <script src="./dist/browser/solvers/glpk.min.js"></script> -->
541
- <!-- <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> -->
542
556
 
543
557
  <script>
544
558
  (async () => {
@@ -553,10 +567,12 @@ For static HTML pages, CodePen, or JSFiddle, load `washi.min.js` via a standard
553
567
 
554
568
  await m.solve();
555
569
 
556
- document.getElementById("output").textContent =
557
- `Status: ${m.status}\n` +
558
- `Objective: ${m.objectiveValue}\n` +
559
- `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
+ }
560
576
  })();
561
577
  </script>
562
578
  </body>
@@ -564,7 +580,7 @@ For static HTML pages, CodePen, or JSFiddle, load `washi.min.js` via a standard
564
580
  ```
565
581
 
566
582
  > [!NOTE]
567
- > When loading `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.
568
584
 
569
585
  ---
570
586
 
@@ -578,83 +594,81 @@ WaSHI never phones home or falls back to public CDNs. To self-host in an air-gap
578
594
  - Optional Z3: `solvers/z3.js` / `solvers/z3.min.js`, `solvers/z3-built.js`, `solvers/z3-built.wasm`
579
595
  2. **Content Security Policy (CSP) Headers**:
580
596
  - Allow WebAssembly compilation (`'wasm-unsafe-eval'` in `script-src`).
581
- - If using the default Web Worker or cross-origin Blob worker fallback, allow `worker-src 'self' blob:;`.
597
+ - If using the default Web Worker or cross-origin Blob worker bootstrap, allow `worker-src 'self' blob:;`.
582
598
  - If `blob:` workers are prohibited by policy, either serve `highs-worker.js` from `'self'` or run on the main thread with `await m.solve({ useWorker: false })`.
583
599
  3. **Cross-Origin Isolation (Z3 Only)**:
584
600
  - HiGHS and GLPK run in standard browser contexts without special headers.
585
- - Z3 WASM uses pthreads + `SharedArrayBuffer` and requires `Cross-Origin-Opener-Policy: same-origin` and `Cross-Origin-Embedder-Policy: require-corp` (or `credentialless`). See the [Cross-Origin Isolation Guide](docs/getting-started/cross-origin.md) for setup details.
586
-
587
- ---
588
-
589
- ## ⚡ Bun & Deno
590
-
591
- ### Bun
592
-
593
- ```bash
594
- bun add @ohtaman/washi
595
- bun run app.ts
596
- ```
597
-
598
- ### Deno
599
-
600
- In Deno (v1.30+ with `npm:` specifiers):
601
-
602
- ```typescript
603
- import { Model, maximize } from "npm:@ohtaman/washi";
604
-
605
- const m = new Model();
606
- const x = m.continuous("x", { lb: 0, ub: 10 });
607
- m.objective(maximize(x));
608
- await m.solve();
609
- console.log("x =", x.value);
610
- ```
601
+ - Z3 WASM uses pthreads + `SharedArrayBuffer` and requires `Cross-Origin-Opener-Policy: same-origin` and `Cross-Origin-Embedder-Policy: require-corp` (`credentialless` works in Chromium/Firefox, whereas Safari requires `require-corp`). See the [Cross-Origin Isolation Guide](docs/getting-started/cross-origin.md) for setup details.
611
602
 
612
603
  ---
613
604
 
614
605
  # Cross-Origin Isolation (Z3 & Browsers)
615
606
 
616
- When running the **Z3** solver 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.
617
611
 
618
612
  ---
619
613
 
620
614
  ## Why is Cross-Origin Isolation Required?
621
615
 
622
- The Z3 WebAssembly engine utilizes multithreading backed by `SharedArrayBuffer` and Web Workers.
623
-
624
- In modern browsers (Chrome, Firefox, Safari, Edge), `SharedArrayBuffer` is disabled by default to mitigate Spectre-style timing side-channel attacks. To re-enable it, the hosting page must be **Cross-Origin Isolated**.
616
+ Z3's WebAssembly build uses multithreading (pthreads) backed by `SharedArrayBuffer` and Web Workers. Modern browsers (Chrome, Firefox, Safari, Edge) disable `SharedArrayBuffer` by default to mitigate Spectre-style side-channel attacks unless the top-level document opts into **Cross-Origin Isolation** (`window.crossOriginIsolated === true`).
625
617
 
626
618
  ---
627
619
 
628
620
  ## Required HTTP Response Headers
629
621
 
630
- 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:
631
623
 
632
624
  ```http
633
625
  Cross-Origin-Opener-Policy: same-origin
634
626
  Cross-Origin-Embedder-Policy: require-corp
635
627
  ```
636
628
 
637
- 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).
638
630
 
639
- ```http
640
- Cross-Origin-Resource-Policy: cross-origin
641
- ```
631
+ > [!NOTE]
632
+ > Chromium and Firefox also support `Cross-Origin-Embedder-Policy: credentialless`, which relaxes subresource CORP requirements, **but Safari (WebKit) does not support `credentialless`**. Use `require-corp` if you support Safari / iOS.
642
633
 
643
634
  ---
644
635
 
645
- ## Solutions by Hosting Environment
636
+ ## Setup by Environment
646
637
 
647
- ### 1. Custom Node.js / Express Server
638
+ ### 1. Vite (`vite.config.ts`)
648
639
 
649
- ```javascript
650
- app.use((req, res, next) => {
651
- res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
652
- res.setHeader("Cross-Origin-Embedder-Policy", "require-corp");
653
- 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
+ },
654
658
  });
655
659
  ```
656
660
 
657
- ### 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`)
658
672
 
659
673
  ```javascript
660
674
  /** @type {import('next').NextConfig} */
@@ -675,35 +689,40 @@ const nextConfig = {
675
689
  module.exports = nextConfig;
676
690
  ```
677
691
 
678
- ### 3. Static Hosting without Header Control (e.g., GitHub Pages)
692
+ ### 4. Custom Node.js / Express Server
693
+
694
+ ```javascript
695
+ app.use((req, res, next) => {
696
+ res.setHeader("Cross-Origin-Opener-Policy", "same-origin");
697
+ res.setHeader("Cross-Origin-Embedder-Policy", "require-corp");
698
+ next();
699
+ });
700
+ ```
679
701
 
680
- Platforms like GitHub Pages or preview environments do not allow customizing response headers directly.
702
+ ### 5. Static Hosting without Header Control (e.g., GitHub Pages)
681
703
 
682
- For these environments, you can use **`coi-serviceworker`** (a lightweight Service Worker that injects isolation headers on the client side):
704
+ Platforms like GitHub Pages do not allow customizing HTTP response headers directly. You can use **`coi-serviceworker`** to inject the isolation headers via a client-side Service Worker:
683
705
 
684
- 1. Place `coi-serviceworker.js` in your website root.
685
- 2. In your HTML `<head>`, include:
706
+ 1. Place `coi-serviceworker.js` at your site root.
707
+ 2. Load it in your HTML `<head>`:
686
708
 
687
709
  ```html
688
710
  <script src="/coi-serviceworker.js"></script>
689
711
  ```
690
712
 
691
- 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`.
692
714
 
693
715
  ---
694
716
 
695
- ## Graceful Degradation in WaSHI
717
+ ## Runtime Check in WaSHI
696
718
 
697
- 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.
698
-
699
- > [!TIP]
700
- > The default **HiGHS** solver engine does not require cross-origin isolation or `SharedArrayBuffer`. It works out of the box in standard browser contexts.
719
+ WaSHI's Z3 adapter checks `crossOriginIsolated` before initializing Z3 in the browser. If Cross-Origin Isolation is missing, `solve()` / `check()` immediately rejects with an actionable error explaining that COOP/COEP headers or a Service Worker are required, rather than hanging silently.
701
720
 
702
721
  ---
703
722
 
704
723
  # Variables & Domains
705
724
 
706
- Variables represent the mathematical decision variables in your optimization model.
725
+ Variables represent the mathematical decision variables in your optimization or logical model.
707
726
  WaSHI provides domain-specific factory methods on the `Model` instance: `m.continuous()`, `m.integer()`, `m.binary()`, and `m.bool()`.
708
727
 
709
728
  ---
@@ -711,7 +730,7 @@ WaSHI provides domain-specific factory methods on the `Model` instance: `m.conti
711
730
  ## Decision Variable Types
712
731
 
713
732
  ### 1. Continuous Variables (`m.continuous`)
714
- Can take any real number value within `[lb, ub]`. 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:
715
734
 
716
735
  ```typescript
717
736
  import { Model } from "@ohtaman/washi";
@@ -723,7 +742,7 @@ const fuel = m.continuous("fuel", { lb: 0, ub: 1000 });
723
742
  ```
724
743
 
725
744
  ### 2. Integer Variables (`m.integer`)
726
- Restricted to 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:
727
746
 
728
747
  ```typescript
729
748
  // Single integer variable bounded in [0, 15]
@@ -731,48 +750,43 @@ const trucks = m.integer("trucks", { lb: 0, ub: 15 });
731
750
  ```
732
751
 
733
752
  ### 3. Binary Variables (`m.binary`)
734
- Restricted to $\{0, 1\}$ as 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:
735
754
 
736
755
  ```typescript
737
- // Single binary variable
756
+ // Single binary variable (lb = 0, ub = 1 automatically)
738
757
  const openWarehouse = m.binary("open_warehouse");
739
- // lb is automatically 0, ub is automatically 1
740
758
  ```
741
759
 
742
760
  ### 4. Propositional Boolean Variables (`m.bool`)
743
- Pure boolean logic propositions for SMT / SAT modeling with Z3:
761
+ Pure boolean propositions (`true` / `false`) for logical and SMT constraints:
744
762
 
745
763
  ```typescript
746
- // Single propositional boolean variable
747
- const isNight = m.bool("isNight");
748
- const alarmTriggered = m.bool("alarmTriggered");
764
+ // Single propositional boolean variables
765
+ const isAdmin = m.bool("isAdmin");
766
+ const hasMfa = m.bool("hasMfa");
749
767
 
750
768
  // First-class boolean operators: and, or, not, implies, xor
751
- m.constraint(alarmTriggered.implies(isNight), "alarm_policy");
769
+ m.constraint(isAdmin.implies(hasMfa), "admin_requires_mfa");
752
770
  ```
753
771
 
754
- ### 💡 Modeling Semi-Continuous Decisions
755
- Semi-continuous variables take values in $\{0\} \cup [L, U]$ (either zero or between a positive lower and upper bound, useful for minimum production batches or operational thresholds).
772
+ ### Choosing Between `m.binary()` and `m.bool()`
756
773
 
757
- 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$):
758
-
759
- ```typescript
760
- import { Model, expr } from "@ohtaman/washi";
761
-
762
- const m = new Model();
763
- const batch = m.continuous("batch", { lb: 0, ub: 100 });
764
- const isActive = m.binary("is_active");
774
+ | Feature | `m.binary()` (`Var`) | `m.bool()` (`BoolVar`) |
775
+ | :--- | :--- | :--- |
776
+ | **Solved `.value` type** | `number \| null` (`0` or `1`) | `boolean \| null` (`true` or `false`) |
777
+ | **Linear arithmetic (`expr`, `sum`, `dot`, objective costs)** | ✅ Native (`` expr`30 * ${b}` ``) | ❌ Cannot be multiplied by numbers or summed directly |
778
+ | **Indicator constraints (`m.indicator(b, ...)` )** | ✅ Supported as trigger variable | ❌ Use `` implies(flag, expr`...`) `` instead |
779
+ | **Propositional logic (`and`, `or`, `not`, `implies`, `xor`)** | Via comparison (`` expr`${b} == 1` ``) | ✅ Native first-class methods (`.implies()`, `.and()`) |
780
+ | **Solver execution** | Native 0-1 MIP on HiGHS/GLPK; bounded integer on Z3 | Native boolean proposition on Z3; auto-linearized to 0-1 MIP on HiGHS/GLPK |
765
781
 
766
- // If isActive === 0, batch is forced to 0; if 1, batch is within [10, 100]
767
- m.constraint(expr`${batch} >= 10 * ${isActive}`, "min_batch_run");
768
- m.constraint(expr`${batch} <= 100 * ${isActive}`, "max_batch_run");
769
- ```
782
+ > [!TIP]
783
+ > Use **`m.binary()`** when a decision participates in arithmetic expressions—such as linear sums ($\sum x_i \ge 3$), objective cost terms ($c_i x_i$), or `m.indicator()`. Use **`m.bool()`** when expressing logical conditions with `implies()`, `and()`, and `or()` without numeric coefficients.
770
784
 
771
785
  ---
772
786
 
773
787
  ## Multi-Dimensional & Array Creation
774
788
 
775
- The domain factory methods (`continuous`, `integer`, `binary`, `bool`) 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.
776
790
 
777
791
  ### By Count
778
792
  Pass a number to create an array of variables:
@@ -783,15 +797,15 @@ const flags = m.bool(3); // BoolVar[] (p_0, p_1, p_2)
783
797
  ```
784
798
 
785
799
  ### 1D Variable Dictionaries
786
- Pass an array of keys to get a typed dictionary `Record<string, Var>` or `Record<string, BoolVar>`:
800
+ Pass an array of keys to get a typed dictionary `Record<K, Var>` or `Record<K, BoolVar>`:
787
801
 
788
802
  ```typescript
789
- const items = ["laptop", "phone", "tablet"];
803
+ const items = ["laptop", "phone", "tablet"] as const;
790
804
 
791
805
  // Record<"laptop" | "phone" | "tablet", Var>
792
806
  const selected = m.binary(items);
793
807
 
794
- const perms = m.bool(["read", "write", "execute"]);
808
+ const perms = m.bool(["read", "write", "execute"] as const);
795
809
  // perms.read, perms.write, perms.execute
796
810
  ```
797
811
 
@@ -825,16 +839,16 @@ const schedule = m.binary(factories, 7, { prefix: "open_day" });
825
839
 
826
840
  | Option | Type | Default | Description |
827
841
  | :--- | :--- | :--- | :--- |
828
- | `lb` | `number` | `0` | Lower bound. Defaults to 0 (non-negative). |
842
+ | `lb` | `number` | `0` | Lower bound. Defaults to `0` (non-negative). |
829
843
  | `ub` | `number` | `Infinity` (or `1` for binary) | Upper bound. |
830
844
  | `type` | `"Continuous"` \| `"Integer"` \| `"Binary"` | Inferred from method | Underlying variable domain category. |
831
- | `initialValue` | `number` | `undefined` | Optional primal solution hint (MIP start), kept distinct from solved `variable.value`. |
845
+ | `initialValue` | `number` | `undefined` | Optional primal solution hint (MIP warm start), kept distinct from solved `variable.value`. |
832
846
 
833
847
  ---
834
848
 
835
849
  ## Primal Initial Values (Warm-Start Hints)
836
850
 
837
- When rebuilding a model across iterations 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 = ...`):
838
852
 
839
853
  ```typescript
840
854
  const prevValues: Record<string, number | null> = { trucks: 8, open_warehouse: 1 };
@@ -851,15 +865,15 @@ const openWarehouse2 = m2.binary("open_warehouse", {
851
865
  const extraCapacity = m2.continuous("extra_capacity", { lb: 0 }); // Partial start: no initialValue needed
852
866
  ```
853
867
 
854
- - **Hint vs. Hard Constraint**: `initialValue` is 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).
855
- - **Validation & Tolerance (`1e-6`)**: Supplied values must be finite numbers within `[lb - 1e-6, ub + 1e-6]` (and within `1e-6` of an integer 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.
856
870
  - **Solver Support**: Supported by **HiGHS** (`capabilities.supportsWarmStart === true`). Solvers without warm-start support (**GLPK**, **Z3**, or custom adapters without `supportsWarmStart: true`) reject `solve()` and `check()` with a descriptive error rather than silently ignoring `initialValue`.
857
871
 
858
872
  ---
859
873
 
860
874
  ## Accessing Solution Values & Reduced Costs
861
875
 
862
- After calling `await m.solve()` or `await m.check()`, assigned variable values (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()`:
863
877
 
864
878
  ```typescript
865
879
  await m.solve();
@@ -867,7 +881,7 @@ await m.solve();
867
881
  // Single variable primal value
868
882
  console.log(`trucks: ${trucks.value}`);
869
883
 
870
- // Reduced cost (LP sensitivity analysis — how much an objective coefficient must improve before a variable at its bound enters the basis)
884
+ // Reduced cost (LP sensitivity analysis)
871
885
  console.log(`fuel reduced cost: ${fuel.reducedCost}`);
872
886
 
873
887
  // Bulk extraction for 1D or 2D collections (works for both Var and BoolVar collections)
@@ -882,11 +896,11 @@ const flowValues = m.getValues(ship);
882
896
 
883
897
  ## Looking Up Variables by Name
884
898
 
885
- You can retrieve registered decision variables by name or inspect the total variable count on the model:
899
+ You can retrieve registered decision variables by name or inspect the total numeric variable count on the model:
886
900
 
887
901
  ```typescript
888
902
  const v = m.getVarByName("trucks"); // Var | undefined
889
- const b = m.getBoolVarByName("isNight"); // BoolVar | undefined
903
+ const b = m.getBoolVarByName("isAdmin"); // BoolVar | undefined
890
904
  console.log("Total numeric variables:", m.numVars);
891
905
  ```
892
906
 
@@ -896,7 +910,7 @@ console.log("Total numeric variables:", m.numVars);
896
910
 
897
911
  Mathematical expressions are linear or quadratic combinations of decision variables and numeric coefficients.
898
912
 
899
- In JavaScript and TypeScript—which do not support operator overloading—WaSHI provides the **unified tagged template literal (`expr`)**, **`sum()`**, and matrix/vector **`dot()`** to construct algebraic expressions 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.
900
914
 
901
915
  | Approach | Example | Infix readability | Type safety & IDE rename | Indexed variables (`flow[i][j]`) |
902
916
  | :--- | :--- | :--- | :--- | :--- |
@@ -906,9 +920,9 @@ In JavaScript and TypeScript—which do not support operator overloading—WaSHI
906
920
 
907
921
  ---
908
922
 
909
- ## ✍️ Tagged Template Literal (`expr`)
923
+ ## Tagged Template Literal (`expr`)
910
924
 
911
- 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.
912
926
 
913
927
  Use `expr` for both arithmetic expressions (`LinExpr | QuadExpr`) and relational constraints (`Constraint`):
914
928
 
@@ -931,44 +945,55 @@ const balanceConstraint = expr`${x} + 2 * ${y} <= 40`;
931
945
 
932
946
  ### Supported Syntax in `expr`
933
947
  - **Addition & Subtraction**: `${x} + ${y} - 5`
934
- - **Scalar Multiplication**: `4 * ${x}`, `${x} * 4`
948
+ - **Scalar Multiplication & Division**: `4 * ${x}`, `${x} / 2`
935
949
  - **Variable Multiplication (Quadratic terms)**: `${x} * ${y}`, `2 * ${x} * ${x}`
936
950
  - **Powers & Exponentiation**: `${x}^2`, `${x}**2`
937
- - **Unary Negation**: `-${x} + 10`
951
+ - **Unary Plus & Negation**: `-${x} + 10`, `+${x}`
938
952
  - **Parentheses**: `2 * (${x} + ${y})`
939
- - **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.
940
956
 
941
957
  > [!NOTE]
942
958
  > For relational comparisons (`<=`, `>=`, `==`), `expr` (e.g., ``expr`${x} + 2 * ${y} <= 40` ``) returns a first-class `Constraint`.
943
- > For arithmetic expressions without comparison operators, `expr` uniformly handles linear and quadratic expressions (up to degree 2): if quadratic terms are present, `expr` returns a `QuadExpr`; otherwise, it returns a `LinExpr`. Polynomial terms of degree $> 2$ (e.g. ``${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.
944
960
 
945
961
  ---
946
962
 
947
- ## ⚡ High-Performance Summation (`sum`)
963
+ ## High-Performance Summation (`sum`)
948
964
 
949
- 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:
950
966
 
951
967
  ```typescript
952
- import { sum } from "@ohtaman/washi";
968
+ import { expr, sum } from "@ohtaman/washi";
953
969
 
954
- const items = ["A", "B", "C"];
970
+ const items = ["A", "B", "C"] as const;
955
971
  const weights = { A: 10, B: 20, C: 30 };
956
972
  const x = m.binary(items);
957
973
 
958
- // 1. 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):
959
981
  const totalWeight = sum(items, (item) => x[item].mul(weights[item]));
960
982
 
961
- // 2. Direct array summation:
962
- const count = sum(Object.values(x));
983
+ // 4. Standalone 1D/2D Record or Array summation:
984
+ const count = sum(x);
963
985
  ```
964
986
 
965
- `sum()` runs in $O(N)$ linear time without AST overhead.
987
+ `sum()` runs in $O(N)$ linear time without AST parsing overhead.
988
+
989
+ > [!TIP]
990
+ > **Avoid $O(N^2)$ `expr` Re-Assignment Inside Loops**: Because `LinExpr` and `expr` results are immutable, accumulating terms one by one via ``total = expr`${total} + ${x[i]}` `` inside a loop copies all previously accumulated terms on every iteration ($O(N^2)$ time and memory). Pass arrays or records to `sum(x)` or `dot(coeffs, x)` for $O(N)$ construction.
966
991
 
967
992
  ---
968
993
 
969
- ## 🎯 Dot Product (`dot`)
994
+ ## Dot Product (`dot`)
970
995
 
971
- The `dot(a, b)` helper computes linear combinations of numeric coefficients (`number`) and variables or linear expressions (`Var | LinExpr`) in either operand order (`dot(coeffs, vars)` or `dot(vars, coeffs)`).
996
+ The `dot(a, b)` helper (usable both inside `` expr`dot(${a}, ${b})` `` and as a standalone function `dot(a, b)`) computes linear combinations of numeric coefficients (`number`) and variables or linear expressions (`Var | LinExpr`) in either operand order (`dot(coeffs, vars)` or `dot(vars, coeffs)`).
972
997
 
973
998
  Supported 1D and 2D container shapes:
974
999
  - **1D Arrays**: `number[]` $\times$ `(Var | LinExpr)[]`
@@ -981,41 +1006,74 @@ Mismatched array lengths, missing dictionary keys, or non-finite/non-linear leav
981
1006
 
982
1007
  ### 1D Vector Dot Product
983
1008
  ```typescript
984
- import { dot } from "@ohtaman/washi";
1009
+ import { expr, dot } from "@ohtaman/washi";
985
1010
 
986
1011
  const profits = { desk: 30, chair: 50 };
987
- const vars = m.integer(["desk", "chair"]);
1012
+ const vars = m.integer(["desk", "chair"] as const);
988
1013
 
989
- // Computes 30 * vars.desk + 50 * vars.chair
990
- const totalProfit = dot(profits, vars);
1014
+ // Computes 30 * vars.desk + 50 * vars.chair inside expr (or via standalone dot(profits, vars))
1015
+ const totalProfit = expr`dot(${profits}, ${vars})`;
991
1016
  ```
992
1017
 
993
- ### 2D Matrix Dot Product
1018
+ ### 2D Matrix Inner Product (Frobenius Inner Product)
994
1019
  Works directly with 2D arrays (`m.continuous(rows, cols)`), nested dictionaries (`m.continuous(rowKeys, colKeys)`), or mixed 2D containers:
995
1020
 
996
1021
  ```typescript
997
- const factories = ["Tokyo", "Osaka"];
998
- const stores = ["StoreA", "StoreB"];
1022
+ const factories = ["Tokyo", "Osaka"] as const;
1023
+ const stores = ["StoreA", "StoreB"] as const;
999
1024
 
1000
1025
  const costs = {
1001
1026
  Tokyo: { StoreA: 4, StoreB: 7 },
1002
1027
  Osaka: { StoreA: 8, StoreB: 3 },
1003
1028
  };
1004
1029
 
1005
- const ship = m.continuous(factories, stores);
1030
+ const ship = m.continuous(factories, stores, { lb: 0 });
1031
+
1032
+ // Total shipping cost across all routes (equivalent to expr`sum(${costs} * ${ship})`):
1033
+ const totalShippingCost = expr`dot(${costs}, ${ship})`;
1034
+ ```
1035
+
1036
+ > [!NOTE]
1037
+ > **2D `dot(A, B)` is the Frobenius Inner Product**: For 2D matrices or nested records, `dot(A, B)` computes the scalar **Frobenius inner product** $\langle A, B \rangle_F = \sum_{i,j} A_{i,j} B_{i,j}$ (the sum of element-wise products across all rows and columns, equivalent to `` expr`sum(${A} * ${B})` ``), **not** the matrix-matrix product $(AB)_{i,k} = \sum_j A_{i,j} B_{j,k}$.
1038
+
1039
+ ---
1040
+
1041
+ ## Composing Aggregations in `expr`
1042
+
1043
+ ### Separating Operators and Data
1006
1044
 
1007
- // Total shipping cost in one line:
1008
- m.objective(minimize(dot(costs, ship)));
1045
+ Write mathematical operators, `sum(...)`, `dot(..., ...)`, and comparison symbols (`<=`, `>=`, `==`) in the template string, and pass variables, scalars, and containers (`Array` / `Record`) via `${...}`:
1046
+
1047
+ ```typescript
1048
+ // Nested template calls and Object.values() (verbose)
1049
+ const totalProfit1 = expr`${dot(profits, vars)}`;
1050
+ const cap1 = expr`${sum(Object.values(vars))} <= 100`;
1051
+
1052
+ // Direct sum() and dot() inside expr (recommended)
1053
+ const totalProfit2 = expr`dot(${profits}, ${vars})`;
1054
+ const cap2 = expr`sum(${vars}) <= 100`;
1009
1055
  ```
1010
1056
 
1057
+ ### Common `expr` Aggregation Syntax
1058
+
1059
+ | Operation | Mathematical Formula | WaSHI Expression |
1060
+ | :--- | :--- | :--- |
1061
+ | **Total sum** (1D/2D `Array` or `Record`) | $\sum_i x_i \le C$ | `` m.constraint(expr`sum(${x}) <= ${C}`) `` |
1062
+ | **Inner product** (1D vector or 2D Frobenius) | $\sum_i c_i x_i \le B$ | `` m.constraint(expr`dot(${c}, ${x}) <= ${B}`) `` |
1063
+ | **Element-wise difference & product** | $\sum_i (p_i - c_i) \cdot x_i$ | `` expr`sum((${price} - ${cost}) * ${x})` `` |
1064
+ | **Sum of squared deviations (QP)** | $\sum_i (x_i - t_i)^2$ | `` expr`sum((${x} - ${target})^2)` `` |
1065
+ | **Element-wise constraints** ($\forall i \in I$) | $a_i x_i \le b_i \quad (\forall i)$ | `` m.constraint(items.map((i) => expr`${a[i]} * ${x[i]} <= ${b[i]}`)) `` |
1066
+ | **2D row sums** (for each row $f$) | $\sum_s X_{f,s} \le S_f \quad (\forall f)$ | `` m.constraint(factories.map((f) => expr`sum(${ship[f]}) <= ${supply[f]}`)) `` |
1067
+ | **2D column sums** (for each column $s$) | $\sum_f X_{f,s} \ge D_s \quad (\forall s)$ | `` m.constraint(stores.map((s) => expr`sum(${factories}, ${(f) => ship[f][s]}) >= ${demand[s]}`)) `` |
1068
+
1011
1069
  ---
1012
1070
 
1013
- ## 🧮 Quadratic Expressions (`QuadExpr` & `expr`)
1071
+ ## Quadratic Expressions (`QuadExpr` & `expr`)
1014
1072
 
1015
- 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()`:
1073
+ WaSHI supports Convex Quadratic Programming (QP) objectives via the HiGHS solver. Quadratic expressions can be written using the `expr` template literal or constructed programmatically with `QuadExpr` and `.mul()`.
1016
1074
 
1017
1075
  ### 1. Using `expr` (Recommended)
1018
- You can write quadratic formulas naturally with `*`, `^2`, or `**2`:
1076
+ Write quadratic terms with `*`, `^2`, or `**2`:
1019
1077
 
1020
1078
  ```typescript
1021
1079
  import { Model, expr, minimize } from "@ohtaman/washi";
@@ -1032,7 +1090,7 @@ m.constraint(expr`${x} + ${y} >= 4`);
1032
1090
  ```
1033
1091
 
1034
1092
  ### 2. Method Chaining with `.mul()` or `QuadExpr`
1035
- Alternatively, you can construct quadratic expressions programmatically:
1093
+ You can also construct quadratic expressions programmatically:
1036
1094
 
1037
1095
  ```typescript
1038
1096
  import { Model, QuadExpr, minimize } from "@ohtaman/washi";
@@ -1041,12 +1099,11 @@ const m = new Model();
1041
1099
  const x = m.continuous("x", { lb: 0 });
1042
1100
  const y = m.continuous("y", { lb: 0 });
1043
1101
 
1044
- // 1. Construct quadratic terms with .mul()
1045
- // Represents x * y + 2 * x^2
1102
+ // 1. Construct quadratic terms with .mul() (x * y + 2 * x^2)
1046
1103
  const quad = x.mul(y).add(x.mul(x).mul(2));
1047
1104
 
1048
- // 2. Build portfolio risk objective: w^T * Sigma * w
1049
- const assets = ["Tech", "Bonds"];
1105
+ // 2. Build portfolio variance objective: w^T * Sigma * w
1106
+ const assets = ["Tech", "Bonds"] as const;
1050
1107
  const w = m.continuous(assets, { lb: 0, ub: 1 });
1051
1108
 
1052
1109
  let risk = new QuadExpr();
@@ -1057,13 +1114,22 @@ risk = risk.add(w.Bonds.mul(w.Bonds).mul(0.01));
1057
1114
  m.objective(minimize(risk));
1058
1115
  ```
1059
1116
 
1117
+ > [!NOTE]
1118
+ > **Convexity Requirement ($\mathbf{Q} \succeq 0$)**: QP solvers (including HiGHS) require the quadratic objective matrix $\mathbf{Q}$ to be **positive semi-definite (PSD)** when minimizing (or negative semi-definite when maximizing). Sum-of-squares terms ($\sum_i (x_i - t_i)^2$) and covariance matrices ($\mathbf{w}^\top \boldsymbol{\Sigma} \mathbf{w}$) are guaranteed PSD, whereas indefinite products over continuous variables (such as minimizing $x \cdot y$) are non-convex and rejected by the solver. *(To linearize the product of two binary variables $x_A \cdot x_B$ in MILP, see [Formulation Patterns & Tips](docs/guide/formulation-tips.md#product-of-two-binary-variables).)*
1119
+
1060
1120
  ---
1061
1121
 
1062
- ## 📊 Evaluating Expressions After Solving (`.value`)
1122
+ ## Evaluating Expressions After Solving (`.value`)
1063
1123
 
1064
- Both `LinExpr` and `QuadExpr` expose a `.value` getter (`number | null`). Once a model has been solved and all referenced variables have finite primal values, you can evaluate any sub-expression—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:
1065
1125
 
1066
1126
  ```typescript
1127
+ import { Model, expr, maximize } from "@ohtaman/washi";
1128
+
1129
+ const m = new Model();
1130
+ const x = m.integer("x", { lb: 0 });
1131
+ const y = m.integer("y", { lb: 0 });
1132
+
1067
1133
  const revenue = expr`30 * ${x} + 50 * ${y}`;
1068
1134
  const laborUsed = expr`2 * ${x} + ${y}`;
1069
1135
 
@@ -1081,7 +1147,7 @@ If the model has not been solved (or any variable in the expression has `value =
1081
1147
 
1082
1148
  ---
1083
1149
 
1084
- ## 🔗 Fluent Arithmetic & Relational Methods
1150
+ ## Fluent Arithmetic & Relational Methods
1085
1151
 
1086
1152
  In addition to `expr`, `Var`, `LinExpr`, and `QuadExpr` provide immutable fluent methods for programmatic expression building:
1087
1153
 
@@ -1101,17 +1167,17 @@ In addition to `expr`, `Var`, `LinExpr`, and `QuadExpr` provide immutable fluent
1101
1167
  # Constraints (`m.constraint` & `m.indicator`)
1102
1168
 
1103
1169
  Constraints restrict the feasible solution space of your mathematical or logical model.
1104
- In WaSHI, both linear arithmetic constraints and propositional SMT formulas are registered using **`m.constraint()`**:
1170
+ In WaSHI, both linear arithmetic constraints and propositional logic formulas are registered using **`m.constraint()`**:
1105
1171
 
1106
1172
  ```typescript
1107
- m.constraint(expr`${desks} + 2 * ${chairs} <= 40`, "wood_limit");
1173
+ m.constraint(expr`${x} + 2 * ${y} <= 40`, "wood_limit");
1108
1174
  ```
1109
1175
 
1110
1176
  ---
1111
1177
 
1112
- ## ✍️ Algebraic Linear Constraints (`expr`)
1178
+ ## Algebraic Linear Constraints (`expr`)
1113
1179
 
1114
- 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:
1115
1181
 
1116
1182
  ```typescript
1117
1183
  import { Model, expr } from "@ohtaman/washi";
@@ -1130,13 +1196,15 @@ m.constraint(expr`3 * ${x} + ${y} >= 20`, "min_demand");
1130
1196
  m.constraint(expr`${x} + ${y} == 30`, "exact_quota");
1131
1197
  ```
1132
1198
 
1199
+ `m.constraint()` returns the registered `Constraint` (or `BoolExpr` for logical formulas), or an array when passed a batch array.
1200
+
1133
1201
  ### Supported Comparison Operators
1134
1202
  - `<=`: Upper bound constraint
1135
1203
  - `>=`: Lower bound constraint
1136
1204
  - `==` or `=`: Exact equality constraint
1137
1205
 
1138
1206
  ### Batch Registration
1139
- 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:
1140
1208
 
1141
1209
  ```typescript
1142
1210
  m.constraint([expr`${x} <= 10`, expr`${y} <= 20`]);
@@ -1144,50 +1212,51 @@ m.constraint([expr`${x} <= 10`, expr`${y} <= 20`]);
1144
1212
 
1145
1213
  ---
1146
1214
 
1147
- ## 🏷️ Naming Constraints
1215
+ ## Naming Constraints
1148
1216
 
1149
- 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)`:
1150
1218
 
1151
1219
  ```typescript
1152
1220
  m.constraint(expr`${x} + ${y} <= 100`, "budget_cap");
1153
1221
  ```
1154
1222
 
1155
1223
  ### Why Name Constraints?
1156
- 1. **Model Inspection (`m.toLpString()`)**: Exported CPLEX LP strings retain these descriptive names (sanitized if needed) so you can inspect the exact generated formulation.
1157
- 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`).
1158
1226
  3. **Direct Lookup (`m.getConstraintByName()`)**: Retrieve a constraint by name after solving to inspect its `dual` or `slack` value.
1159
1227
 
1160
1228
  ---
1161
1229
 
1162
- ## 🔁 Adding Constraints over Iterables & Sums
1230
+ ## Aggregated Constraints (`sum` & `dot`)
1163
1231
 
1164
- 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:
1165
1233
 
1166
1234
  ```typescript
1167
- import { Model, sum, expr } from "@ohtaman/washi";
1235
+ import { Model, expr } from "@ohtaman/washi";
1168
1236
 
1169
- const items = ["A", "B", "C"];
1237
+ const m = new Model();
1238
+ const items = ["A", "B", "C"] as const;
1170
1239
  const weights = { A: 5, B: 10, C: 15 };
1171
1240
  const selected = m.binary(items);
1172
1241
 
1242
+ // Select at most 2 items
1243
+ m.constraint(expr`sum(${selected}) <= 2`, "max_items");
1244
+
1173
1245
  // Total weight cannot exceed capacity 25
1174
- m.constraint(
1175
- expr`${sum(items, (i) => selected[i].mul(weights[i]))} <= 25`,
1176
- "capacity_limit"
1177
- );
1246
+ m.constraint(expr`dot(${weights}, ${selected}) <= 25`, "capacity_limit");
1178
1247
  ```
1179
1248
 
1180
1249
  ---
1181
1250
 
1182
- ## 🎯 Conditional / Indicator Constraints (`m.indicator`)
1251
+ ## Conditional / Indicator Constraints (`m.indicator`)
1183
1252
 
1184
- 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()`:
1185
1254
 
1186
1255
  ```typescript
1187
1256
  const useDiscount = m.binary("use_discount");
1188
1257
  const cartTotal = m.continuous("cart_total", { lb: 0, ub: 10_000 });
1189
1258
 
1190
- // If useDiscount == 1, then cartTotal >= 100
1259
+ // If useDiscount == 1, enforce cartTotal >= 100
1191
1260
  // WaSHI automatically derives a safe, tight Big-M from the variable bounds
1192
1261
  m.indicator(useDiscount, expr`${cartTotal} >= 100`, {
1193
1262
  activeValue: 1,
@@ -1197,16 +1266,16 @@ m.indicator(useDiscount, expr`${cartTotal} >= 100`, {
1197
1266
 
1198
1267
  ### Safe Automatic Big-M Derivation & Bounds
1199
1268
 
1200
- When `options.M` is omitted, WaSHI automatically derives a 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`):
1201
1270
  - For `<=` constraints, variables with positive coefficients require finite upper bounds (`ub`), and negative coefficients require finite lower bounds (`lb`).
1202
1271
  - For `>=` constraints, variables with positive coefficients require finite lower bounds (`lb`), and negative coefficients require finite upper bounds (`ub`).
1203
- - For `==` constraints, 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`).
1204
1273
 
1205
- 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`.
1206
1275
 
1207
- ### Explicit Override & Numerical Stability
1276
+ ### Explicit `M` Override
1208
1277
 
1209
- You can override the derived Big-M by providing `{ M: number }`:
1278
+ You can override the derived Big-M by passing `{ M: number }`:
1210
1279
 
1211
1280
  ```typescript
1212
1281
  m.indicator(useDiscount, expr`${cartTotal} >= 100`, {
@@ -1216,15 +1285,13 @@ m.indicator(useDiscount, expr`${cartTotal} >= 100`, {
1216
1285
  ```
1217
1286
 
1218
1287
  > [!TIP]
1219
- > **Numerical Stability vs. Correctness**:
1220
- > An $M$ value that is too small can inadvertently cut off valid solutions. Conversely, an excessively large $M$ (e.g., $10^9$) causes poor matrix conditioning and numerical instability in MIP solvers.
1221
- > By setting explicit, realistic variable bounds (`lb` and `ub`), WaSHI derives the tightest possible safe Big-M automatically.
1288
+ > **Prefer Finite Variable Bounds Over Arbitrary Large `M`**: An $M$ value that is too small can cut off valid feasible solutions, whereas an excessively large $M$ (e.g., $10^9$) degrades numerical conditioning in MIP solvers. Setting realistic `lb` and `ub` bounds on variables lets WaSHI compute the tightest safe Big-M automatically.
1222
1289
 
1223
1290
  ---
1224
1291
 
1225
- ## 🧠 Propositional Logic & SMT Constraints (`BoolVar`, `and`, `or`, `not`, `implies`, `xor`)
1292
+ ## Propositional Logic & SMT Constraints (`BoolVar`, `and`, `or`, `not`, `implies`, `xor`)
1226
1293
 
1227
- 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**:
1228
1295
 
1229
1296
  ```typescript
1230
1297
  import { Model, expr, and, or, not, implies, xor } from "@ohtaman/washi";
@@ -1233,14 +1300,14 @@ import "@ohtaman/washi/solvers/z3";
1233
1300
  const m = new Model({ solver: "z3" });
1234
1301
  const isAdmin = m.bool("isAdmin");
1235
1302
  const hasMfa = m.bool("hasMfa");
1236
- const Clearance = m.integer("clearance", { lb: 0, ub: 5 });
1303
+ const clearance = m.integer("clearance", { lb: 0, ub: 5 });
1237
1304
 
1238
1305
  // Method syntax on BoolVar / BoolOp:
1239
1306
  m.constraint(isAdmin.implies(hasMfa), "admin_requires_mfa");
1240
1307
 
1241
1308
  // Mixing boolean propositions with linear arithmetic comparisons:
1242
1309
  m.constraint(
1243
- implies(isAdmin, expr`${Clearance} >= 4`),
1310
+ implies(isAdmin, expr`${clearance} >= 4`),
1244
1311
  "admin_clearance_level"
1245
1312
  );
1246
1313
 
@@ -1254,27 +1321,32 @@ m.constraint(and(or(isAdmin, hasMfa), not(xor(isAdmin, hasMfa))), "policy");
1254
1321
  | :--- | :--- | :--- |
1255
1322
  | **Trigger variable** | Numeric `Binary` variable (`m.binary()`) | `BoolVar` (`m.bool()`), `BoolOp`, or `Constraint` |
1256
1323
  | **Consequent** | Linear `Constraint` (`<=`, `>=`, `==`) | `BoolVar`, `BoolOp`, or linear `Constraint` |
1257
- | **Target Solvers** | **All MIP/LP solvers** (HiGHS, GLPK, Z3) via Big-M linearization | **Z3 SMT solver** (`capabilities.supportsSMT: true`) |
1258
- | **Best For** | Operations research & MIP optimization with an objective (`m.solve()`) | Rule engines, policy verification, and SMT feasibility (`m.check()`) |
1324
+ | **Target Solvers** | **All MIP/LP solvers** (HiGHS, GLPK, Z3) via Big-M linearization | **All solvers** (HiGHS and GLPK via automatic 0-1 MIP linearization, Z3 natively) |
1325
+ | **Primary Use Case** | MIP optimization models with arithmetic binary variables (`m.solve()`) | Logical rule systems and SMT feasibility (`m.check()`) |
1326
+
1327
+ > [!NOTE]
1328
+ > **General Inequality Antecedents in `implies()`**: On **Z3** (`solver: "z3"`), the antecedent `a` in `implies(a, b)` can be any linear inequality—for example, ``implies(expr`${clearance} >= 4`, hasMfa)`` using `clearance` and `hasMfa` above. On MIP solvers (**HiGHS** / **GLPK**), conditional constraints should use a `BoolVar` (`m.bool()`) antecedent or `m.indicator(binVar, constraint)` with a `Binary` trigger variable.
1259
1329
 
1260
1330
  ---
1261
1331
 
1262
- ## 📐 Dual Values, Shadow Prices & Lookups
1332
+ ## Dual Values, Shadow Prices & Lookups
1263
1333
 
1264
- 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()`):
1265
1335
 
1266
1336
  ```typescript
1267
- m.constraint(expr`${x} + 2 * ${y} <= 40`, "wood");
1337
+ const woodConstraint = m.constraint(expr`${x} + 2 * ${y} <= 40`, "wood");
1268
1338
 
1269
1339
  await m.solve();
1270
1340
 
1271
- const woodConstraint = m.getConstraintByName("wood");
1272
- console.log("Shadow price (dual):", woodConstraint?.dual);
1273
- console.log("Slack surplus:", woodConstraint?.slack);
1341
+ console.log("Shadow price (dual):", woodConstraint.dual);
1342
+ console.log("Slack surplus:", woodConstraint.slack);
1274
1343
  console.log("Total linear constraints:", m.numConstrs);
1275
1344
  ```
1276
1345
 
1277
- For logical constraints 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).
1278
1350
 
1279
1351
  ---
1280
1352
 
@@ -1305,9 +1377,12 @@ m.constraint(expr`2 * ${x} + 3 * ${y} <= 100`, "resource_limit");
1305
1377
 
1306
1378
  Calling `m.objective()` with no arguments returns the current objective expression (`LinExpr | QuadExpr`). If you omit setting an objective, the model defaults to minimizing `0`.
1307
1379
 
1380
+ > [!TIP]
1381
+ > For objective formulation patterns—such as **Min-Max / Max-Min load balancing**, **weighted-sum vs. lexicographic multi-objective optimization**, and **MIP stopping tolerances (`relGap` / `absGap`)**—see [Formulation Patterns & Optimization Tips](docs/guide/formulation-tips.md#_4-multi-objective-mip-stopping-design).
1382
+
1308
1383
  ---
1309
1384
 
1310
- ## 🚀 Solving the Model (`m.solve()` vs. `m.check()`)
1385
+ ## Solving the Model (`m.solve()` vs. `m.check()`)
1311
1386
 
1312
1387
  WaSHI provides two asynchronous execution entrypoints:
1313
1388
 
@@ -1343,7 +1418,7 @@ You can also pass a solver name directly as the first argument: `await m.solve("
1343
1418
 
1344
1419
  ---
1345
1420
 
1346
- ## ⏱️ Real-Time Progress (`onProgress`) & Cancellation (`AbortSignal`)
1421
+ ## Real-Time Progress (`onProgress`) & Cancellation (`AbortSignal`)
1347
1422
 
1348
1423
  For long-running MIP optimizations in interactive UIs, you can stream live branch-and-bound progress (`SolveProgressEvent`) and allow users to cancel the solve cleanly with `AbortController`:
1349
1424
 
@@ -1389,7 +1464,7 @@ console.log("Has incumbent:", m.hasPrimalSolution); // true if an incumbent was
1389
1464
 
1390
1465
  ---
1391
1466
 
1392
- ## 📊 Reading Results, Bounds & Solver Status
1467
+ ## Reading Results, Bounds & Solver Status
1393
1468
 
1394
1469
  After solving, query the status, optimal values, and MIP optimality bounds on the `Model`:
1395
1470
 
@@ -1437,7 +1512,7 @@ If an integer-feasible incumbent was found before hitting `timeLimit` or `signal
1437
1512
 
1438
1513
  ---
1439
1514
 
1440
- ## 🔄 Model Mutations & Execution Validity
1515
+ ## Model Mutations & Execution Validity
1441
1516
 
1442
1517
  A `Model` instance keeps its solution state synchronized across mutations made through `Model` methods and `Model` / `Var` / `Constraint` property setters:
1443
1518
 
@@ -1457,13 +1532,13 @@ When building real-world optimization and configuration systems, two verificatio
1457
1532
  1. **Why is my model infeasible?** Which specific business rules contradict each other?
1458
1533
  2. **Does the solver's solution genuinely satisfy every constraint?** How do I verify tolerances, integrality, and formulations?
1459
1534
 
1460
- WaSHI provides built-in tools for both: **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`).
1461
1536
 
1462
1537
  ---
1463
1538
 
1464
- ## 🔍 Conflict Diagnosis (`unsatCore` / `conflicts`)
1539
+ ## Conflict Diagnosis (`unsatCore` / `conflicts`)
1465
1540
 
1466
- 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.
1467
1542
  By calling **`await m.check({ solver: "z3" })`**, WaSHI uses Z3's `assertAndTrack` mechanism to compute an **Unsatisfiable Core (`unsatCore`)**—a conflicting subset of named constraints that cannot be satisfied simultaneously:
1468
1543
 
1469
1544
  ```typescript
@@ -1495,109 +1570,613 @@ Notice that `routine_operating_cost` is **not** in the core, because only `minim
1495
1570
  > - **Use `m.check({ solver: "z3" })` for Unsat Core extraction**: `m.solve()` runs optimization (`Z3.Optimize`), whereas `m.check()` runs tracked satisfiability checking (`Z3.Solver.assertAndTrack`) and populates `result.conflicts` / `m.conflicts` (and alias `m.unsatCore`).
1496
1571
  > - **Name your constraints**: Variable bounds (`lb`, `ub`) act as background domain declarations. To include a bound or business rule in the reported `unsatCore` list, register it as a named constraint via `m.constraint(expr`...`, "rule_name")`.
1497
1572
 
1498
- ### Workflow: Optimize with HiGHS, Diagnose with Z3
1573
+ ### Workflow: Infeasibility Diagnosis with HiGHS or Z3
1499
1574
 
1500
- Because WaSHI models are solver-independent, you can solve your optimization model with **HiGHS** first and automatically fall back to **Z3** `m.check()` if HiGHS reports `"Infeasible"`:
1575
+ Because WaSHI models are solver-independent, you can diagnose infeasibilities using either solver:
1576
+ 1. **Built-in HiGHS & GLPK (default)**: `await m.check()` uses a single-solve elastic slack formulation ($\min \sum s_i$) to identify which constraints required relaxation (`diag.conflicts`), requiring no extra dependencies.
1577
+ 2. **Z3 SMT Solver**: `await m.check({ solver: "z3" })` uses SMT tracking to extract an exact unsatisfiable core (`diag.unsatCore` / `diag.conflicts`).
1501
1578
 
1502
1579
  ```typescript
1503
1580
  const status = await m.solve(); // Fast MIP solve with default HiGHS
1504
1581
 
1505
- if (status === "Infeasible") {
1506
- const diag = await m.check({ solver: "z3" });
1507
- console.error("Model is infeasible due to conflicting rules:", diag.conflicts);
1508
- }
1582
+ if (status === "Infeasible") {
1583
+ // Option 1: Built-in single-solve elastic diagnosis with HiGHS
1584
+ const diag = await m.check();
1585
+ console.error("Model is infeasible due to conflicting rules:", diag.conflicts);
1586
+
1587
+ // Option 2: Exact SMT unsat core diagnosis with Z3 (requires import "@ohtaman/washi/solvers/z3")
1588
+ // const z3Diag = await m.check({ solver: "z3" });
1589
+ // console.error("Z3 Unsat Core:", z3Diag.unsatCore);
1590
+ }
1591
+ ```
1592
+
1593
+ ### Comparing Z3 `unsatCore` vs. HiGHS Elastic Slack `conflicts`
1594
+
1595
+ | Property | Z3 (`m.check({ solver: "z3" })`) | HiGHS / GLPK (`m.check()`) |
1596
+ | :--- | :--- | :--- |
1597
+ | **Algorithm** | SMT assumption tracking (`assertAndTrack`) | Single-solve $L_1$ elastic slack relaxation ($\min \sum s_i$) |
1598
+ | **Populated Fields** | Both `result.unsatCore` and `result.conflicts` | `result.conflicts` (`unsatCore` is `undefined`) |
1599
+ | **Semantic Meaning** | A **contradictory subset** of rules that cannot all hold together | A **minimum-violation repair set** of constraints whose relaxation restores feasibility |
1600
+ | **Unit-Scaling Sensitivity** | **Scale-invariant** (pure logical/algebraic contradiction) | **Scale-sensitive** (minimizing unweighted $\sum s_i$ prefers relaxing a headcount constraint by `2` units over a budget constraint by `10,000` dollars) |
1601
+
1602
+ > [!TIP]
1603
+ > When diagnosing models that mix constraints of vastly different magnitudes (e.g., headcount $\le 8$ alongside budget $\le 50{,}000$), use **`m.check({ solver: "z3" })`** if you want the complete scale-invariant set of mutually contradictory rules, or normalize constraint scales when relying on unweighted $L_1$ slack relaxation.
1604
+
1605
+ ---
1606
+
1607
+ ## Independent Solution Validation (`m.validateSolution()`)
1608
+
1609
+ After solving a model (or supplying a custom candidate map via `values`), you can independently verify that all variable bounds, integer/binary domains, linear constraints, and logical SMT formulas hold against the original model definitions:
1610
+
1611
+ ```typescript
1612
+ const status = await m.solve();
1613
+
1614
+ if (m.hasPrimalSolution) {
1615
+ const report = m.validateSolution({
1616
+ feasibilityTolerance: 1e-6, // Max allowed violation for bounds and linear constraints
1617
+ integralityTolerance: 1e-6, // Max distance from nearest integer for Integer/Binary vars
1618
+ evaluateLogicalConstraints: true,
1619
+ });
1620
+
1621
+ console.log("Valid:", report.valid);
1622
+ console.log("Complete:", report.complete);
1623
+ console.log("Max violation:", report.maxViolation);
1624
+ console.log("Summary:", report.summary);
1625
+
1626
+ if (!report.valid) {
1627
+ console.warn("Violations detected:", report.violations);
1628
+ }
1629
+ }
1630
+ ```
1631
+
1632
+ ### `SolutionValidationReport` Summary
1633
+
1634
+ | Property | Type | Description |
1635
+ | :--- | :--- | :--- |
1636
+ | `valid` | `boolean` | `true` iff `complete === true`, `uncheckedConstraints.length === 0`, and `violations.length === 0`. |
1637
+ | `complete` | `boolean` | `true` when all variables, Boolean variables, linear constraints, and logical constraints were evaluated. |
1638
+ | `maxViolation` | `number` | Largest numeric violation magnitude across all evaluated bounds, integrality checks, and constraints. |
1639
+ | `violations` | `SolutionViolation[]` | Flat list of violations (`"missing_value"`, `"non_finite_value"`, `"invalid_boolean_value"`, `"lower_bound"`, `"upper_bound"`, `"integrality"`, `"linear_constraint"`, `"logical_constraint"`, `"unchecked_constraint"`). |
1640
+ | `violationsByName` | `Record<string, SolutionViolation[]>` | Violations grouped by variable or constraint name. |
1641
+ | `uncheckedConstraints` | `string[]` | Names of constraints that could not be evaluated. |
1642
+ | `missingVariables` | `string[]` | Names of numeric or Boolean variables whose values were `null` or `undefined`. |
1643
+ | `summary` | `SolutionValidationSummary` | Counts of checked vs. total variables, Boolean variables, linear constraints, and logical constraints. |
1644
+
1645
+ ---
1646
+
1647
+ ## Inspecting the LP Formulation (`m.toLpString()`)
1648
+
1649
+ When debugging coefficients, signs, or Big-M indicator expansions, call `m.toLpString()` to export the model in standard CPLEX/HiGHS `.lp` format:
1650
+
1651
+ ```typescript
1652
+ const m = new Model("DebugModel");
1653
+ const x = m.integer("x", { lb: 0, ub: 10 });
1654
+ m.objective(maximize(expr`5 * ${x}`));
1655
+ m.constraint(expr`2 * ${x} <= 15`, "resource_limit");
1656
+
1657
+ console.log(m.toLpString());
1658
+ /*
1659
+ Maximize
1660
+ obj: 5 x
1661
+ Subject To
1662
+ resource_limit: 2 x <= 15
1663
+ Bounds
1664
+ 0 <= x <= 10
1665
+ Generals
1666
+ x
1667
+ End
1668
+ */
1669
+ ```
1670
+
1671
+ ---
1672
+
1673
+ ## Unit Testing Application Logic with `MockSolver`
1674
+
1675
+ When running unit tests for application code that builds or consumes a WaSHI `Model`, you can use the built-in `MockSolver` (`solver: "mock"`) to return deterministic results instantaneously without loading WebAssembly:
1676
+
1677
+ ```typescript
1678
+ import { Model, MockSolver, expr, maximize } from "@ohtaman/washi";
1679
+
1680
+ const mock = new MockSolver({
1681
+ status: "Optimal",
1682
+ objectiveValue: 420,
1683
+ values: { x: 10, y: 20 },
1684
+ });
1685
+
1686
+ const m = new Model({ solver: mock });
1687
+ const x = m.integer("x");
1688
+ const y = m.integer("y");
1689
+ m.objective(maximize(expr`10 * ${x} + 16 * ${y}`));
1690
+
1691
+ await m.solve();
1692
+ console.log(m.objectiveValue); // 420
1693
+ console.log(x.value, y.value); // 10, 20
1694
+ ```
1695
+
1696
+ ---
1697
+
1698
+ # Decomposition Methods (`solveColumnGeneration` & `solveBenders`)
1699
+
1700
+ When monolithic Mixed-Integer Programming (MIP) models have exponentially many candidate patterns or large multi-stage subproblems, WaSHI provides two high-level decomposition orchestrators—`master.solveColumnGeneration()` and `master.solveBenders()`—along with low-level primitives for dual inspection and dynamic model updates.
1701
+
1702
+ | Decomposition Method | WaSHI Orchestrator | Typical Use Case |
1703
+ | :--- | :--- | :--- |
1704
+ | **Column Generation** *(Dantzig-Wolfe)* | [`master.solveColumnGeneration()`](#column-generation-solvecolumngeneration) | Models with exponentially many patterns, routes, or schedules (e.g., Cutting Stock, Vehicle Routing, Crew Rostering). |
1705
+ | **Benders Decomposition** *(and Lazy Cuts)* | [`master.solveBenders()`](#benders-decomposition-solvebenders) | Two-stage or complicating-variable problems (e.g., Facility Location + Dispatch, Stochastic Planning, TSP Subtour Elimination). |
1706
+
1707
+ ---
1708
+
1709
+ ## Foundational Primitives
1710
+
1711
+ WaSHI exposes four lower-level primitives used internally by the decomposition orchestrators and available directly for custom algorithms:
1712
+
1713
+ ### 1. Constraint Duals (`c.dual`)
1714
+ After solving a continuous linear program (LP), read the shadow price (marginal impact on the objective per unit change in RHS) of each constraint:
1715
+
1716
+ ```typescript
1717
+ import { Model, expr, maximize } from "@ohtaman/washi";
1718
+
1719
+ const m = new Model();
1720
+ const x = m.continuous("x", { lb: 0 });
1721
+ const y = m.continuous("y", { lb: 0 });
1722
+
1723
+ m.objective(maximize(expr`3 * ${x} + 2 * ${y}`));
1724
+ const c = m.constraint(expr`${x} + ${y} <= 10`, "capacity");
1725
+ await m.solve();
1726
+
1727
+ console.log(c.dual); // Dual value / shadow price
1728
+ console.log(m.solution?.duals.capacity); // Lookup by constraint name
1729
+ ```
1730
+
1731
+ ### 2. Continuous LP Relaxation (`m.relax()`)
1732
+ `m.relax()` temporarily relaxes all `Integer` and `Binary` variables to continuous variables within their `[lb, ub]` bounds and solves the LP relaxation so constraint duals are populated:
1733
+
1734
+ ```typescript
1735
+ // Solves the continuous LP relaxation and populates m.solution.duals
1736
+ const status = await m.relax();
1737
+ console.log("LP relaxation bound:", m.objectiveValue);
1738
+ ```
1739
+
1740
+ ### 3. Dynamic Column Injection (`m.addColumn()`)
1741
+ Create a new decision variable, set its objective coefficient, and attach its non-zero coefficients to existing named constraints in a single call:
1742
+
1743
+ ```typescript
1744
+ const col = m.addColumn({
1745
+ name: "pattern_1",
1746
+ type: "Integer", // or "Continuous" / "Binary"
1747
+ lb: 0,
1748
+ cost: 1, // Objective coefficient
1749
+ coefficients: {
1750
+ capacity: 2,
1751
+ },
1752
+ });
1753
+ ```
1754
+
1755
+ ### 4. Dynamic Constraint Term Updates (`c.addTerm()` / `c.setTerm()`)
1756
+ Increment or overwrite the coefficient of a variable inside an existing linear constraint:
1757
+
1758
+ ```typescript
1759
+ const extra = m.continuous("extra", { lb: 0 });
1760
+ const budget = m.constraint(expr`${x} + ${y} <= 20`, "budget");
1761
+
1762
+ budget.addTerm(extra, 5); // Adds +5 * extra to the LHS
1763
+ budget.setTerm(extra, 0); // Setting 0 removes extra from the constraint
1764
+ ```
1765
+
1766
+ ---
1767
+
1768
+ ## Column Generation (`solveColumnGeneration`)
1769
+
1770
+ `master.solveColumnGeneration(options)` starts from a **Restricted Master Problem (RMP)** containing an initial feasible subset of columns and executes the column generation loop:
1771
+ 1. Solves the continuous LP relaxation of the master problem via `master.relax()`.
1772
+ 2. Invokes your `pricing` callback with the current dual shadow prices (`duals`, `getDual`).
1773
+ 3. Evaluates candidate columns (`ColumnProposal` or `ColumnProposal[]`), dynamically injecting those with improving reduced costs ($\bar{c} = \text{cost} - \sum_i \pi_i a_i < 0$ when minimizing).
1774
+ 4. Repeats until `pricing` returns `null` / `[]` or no column improves the objective beyond `reducedCostTolerance`.
1775
+ 5. Optionally solves the final master model as a MIP (`finalizeAsMIP: true`, the default) to obtain an integer solution over all generated columns.
1776
+
1777
+ ### Pricing with a WaSHI Subproblem Model
1778
+
1779
+ ```typescript
1780
+ import { Model, expr, minimize, maximize } from "@ohtaman/washi";
1781
+
1782
+ const rollWidth = 100;
1783
+
1784
+ // 1. Restricted Master Problem (RMP) with simple single-item initial patterns
1785
+ const master = new Model("CuttingStockMaster");
1786
+ const pA = master.integer("init_A", { lb: 0 }); // 2 of A (width 45) per roll
1787
+ const pB = master.integer("init_B", { lb: 0 }); // 2 of B (width 36) per roll
1788
+ const pC = master.integer("init_C", { lb: 0 }); // 6 of C (width 16) per roll
1789
+
1790
+ master.objective(minimize(expr`${pA} + ${pB} + ${pC}`));
1791
+ const demandA = master.constraint(expr`2 * ${pA} >= 10`, "demand_A");
1792
+ const demandB = master.constraint(expr`2 * ${pB} >= 14`, "demand_B");
1793
+ const demandC = master.constraint(expr`6 * ${pC} >= 20`, "demand_C");
1794
+
1795
+ // 2. Run Column Generation
1796
+ const result = await master.solveColumnGeneration({
1797
+ finalizeAsMIP: true,
1798
+ pricing: async () => {
1799
+ // Knapsack pricing subproblem: find a pattern maximizing total dual value
1800
+ const sub = new Model("KnapsackPricing");
1801
+ const aA = sub.integer("a_A", { lb: 0, ub: 2 });
1802
+ const aB = sub.integer("a_B", { lb: 0, ub: 2 });
1803
+ const aC = sub.integer("a_C", { lb: 0, ub: 6 });
1804
+
1805
+ const dualA = demandA.dual ?? 0;
1806
+ const dualB = demandB.dual ?? 0;
1807
+ const dualC = demandC.dual ?? 0;
1808
+ sub.objective(maximize(expr`${dualA} * ${aA} + ${dualB} * ${aB} + ${dualC} * ${aC}`));
1809
+ sub.constraint(expr`45 * ${aA} + 36 * ${aB} + 16 * ${aC} <= ${rollWidth}`);
1810
+
1811
+ await sub.solve();
1812
+
1813
+ return {
1814
+ cost: 1,
1815
+ type: "Integer",
1816
+ coefficients: {
1817
+ demand_A: Math.round(aA.value ?? 0),
1818
+ demand_B: Math.round(aB.value ?? 0),
1819
+ demand_C: Math.round(aC.value ?? 0),
1820
+ },
1821
+ };
1822
+ },
1823
+ });
1824
+
1825
+ console.log("Optimal rolls:", result.objectiveValue); // 14
1826
+ console.log("Iterations:", result.iterations);
1827
+ console.log("Columns added:", result.columnsAdded);
1828
+ ```
1829
+
1830
+ ### Pricing with a Custom Algorithm (`getDual`)
1831
+
1832
+ Because `pricing` is a standard callback receiving `PricingContext`, you can also solve the pricing problem directly in TypeScript (e.g., via combinatorial search, dynamic programming, or shortest-path routines) instead of constructing a subproblem `Model`:
1833
+
1834
+ ```typescript
1835
+ const customResult = await master.solveColumnGeneration({
1836
+ maxIterations: 100,
1837
+ reducedCostTolerance: 1e-6,
1838
+ pricing: ({ getDual }) => {
1839
+ const dA = getDual("demand_A");
1840
+ const dB = getDual("demand_B");
1841
+ const dC = getDual("demand_C");
1842
+
1843
+ // Search feasible patterns (45*aA + 36*aB + 16*aC <= rollWidth) for negative reduced cost (1 - dualSum < -1e-6)
1844
+ let bestDualSum = 1 + 1e-6;
1845
+ let best: { demand_A: number; demand_B: number; demand_C: number } | null = null;
1846
+ for (let aA = 0; 45 * aA <= rollWidth; aA++) {
1847
+ for (let aB = 0; 45 * aA + 36 * aB <= rollWidth; aB++) {
1848
+ for (let aC = 0; 45 * aA + 36 * aB + 16 * aC <= rollWidth; aC++) {
1849
+ const dualSum = dA * aA + dB * aB + dC * aC;
1850
+ if (dualSum > bestDualSum) {
1851
+ bestDualSum = dualSum;
1852
+ best = { demand_A: aA, demand_B: aB, demand_C: aC };
1853
+ }
1854
+ }
1855
+ }
1856
+ }
1857
+ return best ? { cost: 1, type: "Integer", coefficients: best } : null;
1858
+ },
1859
+ });
1860
+ ```
1861
+
1862
+ ---
1863
+
1864
+ ## Benders Decomposition (`solveBenders`)
1865
+
1866
+ `master.solveBenders(options)` partitions a problem into a **Master Problem** (holding first-stage or complicating variables) and a **Subproblem** evaluated at each master candidate solution to generate **Optimality Cuts** or **Feasibility Cuts**.
1867
+
1868
+ ### Automatic Cut Generation (`sub.link()`)
1869
+
1870
+ Return a continuous LP subproblem `Model` from `subproblem` and declare cross-model constraints via `sub.link(...)`. WaSHI automatically manages the auxiliary cost estimator variable $\theta$, substitutes master candidate values, extracts subproblem duals, and adds Benders cuts until convergence:
1871
+
1872
+ ```typescript
1873
+ import { Model, expr, minimize } from "@ohtaman/washi";
1874
+
1875
+ // 1. Master problem: binary facility opening decisions
1876
+ const master = new Model("FacilityMaster");
1877
+ const y1 = master.binary("open_F1");
1878
+ const y2 = master.binary("open_F2");
1879
+
1880
+ // Fixed opening costs (theta for operational subproblem cost is managed automatically)
1881
+ master.objective(minimize(expr`100 * ${y1} + 120 * ${y2}`));
1882
+ master.constraint(expr`50 * ${y1} + 60 * ${y2} >= 75`, "total_capacity_floor");
1883
+
1884
+ // 2. Solve with automatic Benders cut generation
1885
+ const result = await master.solveBenders({
1886
+ gapTolerance: 1e-4,
1887
+ subproblem: () => {
1888
+ const sub = new Model("DispatchSubproblem");
1889
+ const x11 = sub.continuous("x_11", { lb: 0 });
1890
+ const x12 = sub.continuous("x_12", { lb: 0 });
1891
+ const x13 = sub.continuous("x_13", { lb: 0 });
1892
+ const x21 = sub.continuous("x_21", { lb: 0 });
1893
+ const x22 = sub.continuous("x_22", { lb: 0 });
1894
+ const x23 = sub.continuous("x_23", { lb: 0 });
1895
+
1896
+ sub.objective(
1897
+ minimize(
1898
+ expr`3 * ${x11} + 4 * ${x12} + 2 * ${x13} + 4 * ${x21} + 2 * ${x22} + 3 * ${x23}`
1899
+ )
1900
+ );
1901
+
1902
+ sub.constraint(expr`${x11} + ${x21} >= 20`, "demand_C1");
1903
+ sub.constraint(expr`${x12} + ${x22} >= 25`, "demand_C2");
1904
+ sub.constraint(expr`${x13} + ${x23} >= 30`, "demand_C3");
1905
+
1906
+ // Link subproblem dispatch flows to master binary variables y1, y2
1907
+ sub.link(expr`${x11} + ${x12} + ${x13} <= 50 * ${y1}`);
1908
+ sub.link(expr`${x21} + ${x22} + ${x23} <= 60 * ${y2}`);
1909
+
1910
+ return sub;
1911
+ },
1912
+ });
1913
+
1914
+ console.log("Optimal total cost:", result.objectiveValue); // 390
1915
+ console.log("Cuts added:", result.cutsAdded);
1916
+ console.log("Open facilities:", result.values);
1917
+ ```
1918
+
1919
+ > [!NOTE]
1920
+ > **`theta` Lower Bound for Negative Subproblem Objectives**: When `sub.link(...)` is used without an explicit `theta` variable, WaSHI injects `__benders_theta` with default `lb: 0` (assuming non-negative subproblem costs). If your subproblem objective can take negative values (e.g., rebate or profit terms), declare `const theta = master.continuous("theta", { lb: -1e6 })`, include `+ ${theta}` in `master.objective(...)`, and pass `{ theta }` in `solveBenders(options)`.
1921
+
1922
+ ### Manual Cut Generation (`BendersCut` / Lazy Constraints)
1923
+
1924
+ For combinatorial separation (such as lazy subtour elimination) or custom analytical cuts, return one `BendersCut` object, an array `BendersCut[]`, or `null` when converged:
1925
+
1926
+ ```typescript
1927
+ import { Model, expr, minimize } from "@ohtaman/washi";
1928
+
1929
+ const master = new Model("ManualBenders");
1930
+ const y = master.binary("y");
1931
+ const theta = master.continuous("theta", { lb: 0 });
1932
+ master.objective(minimize(expr`40 * ${y} + ${theta}`));
1933
+
1934
+ const result = await master.solveBenders({
1935
+ theta,
1936
+ gapTolerance: 1e-4,
1937
+ subproblem: ({ getMasterValue }) => {
1938
+ const yVal = getMasterValue(y);
1939
+ const estTheta = getMasterValue(theta);
1940
+
1941
+ // Operational cost is 100 when y = 0, or 30 when y = 1
1942
+ const actualSubCost = 100 - 70 * yVal;
1943
+ if (estTheta < actualSubCost - 1e-4) {
1944
+ return {
1945
+ kind: "optimality", // or "feasibility" for lazy combinatorial cuts
1946
+ constraint: expr`${theta} >= 100 - 70 * ${y}`,
1947
+ subproblemObjective: 40 * yVal + actualSubCost,
1948
+ };
1949
+ }
1950
+ return null; // Converged
1951
+ },
1952
+ });
1953
+
1954
+ console.log(result.objectiveValue); // 70 (y = 1, theta = 30)
1955
+ ```
1956
+
1957
+ ---
1958
+
1959
+ ## Related Examples & Formulation Tips
1960
+
1961
+ - **Full Decomposition Walkthroughs**:
1962
+ - [Vehicle Routing Problem (`solveColumnGeneration`)](docs/examples/vehicle-routing.md)
1963
+ - [Traveling Salesperson Problem (`solveBenders` lazy subtour elimination)](docs/examples/traveling-salesperson.md)
1964
+ - **Formulation Tips**: See [Formulation Patterns & Optimization Tips](docs/guide/formulation-tips.md#_5-decomposition-formulation-tips) for Set Covering (`>= 1`) vs. Set Partitioning (`== 1`), Price-and-Branch (`finalizeAsMIP`), and multi-cut separation.
1965
+
1966
+ ---
1967
+
1968
+ # Formulation Patterns & Optimization Tips
1969
+
1970
+ While the preceding Modeling Guide pages cover WaSHI's APIs (`Model`, `expr`, `m.constraint`, `m.solve`, etc.), this page collects standard mathematical optimization formulation patterns and solver tuning techniques frequently used in linear and mixed-integer programming (LP/MIP).
1971
+
1972
+ ---
1973
+
1974
+ ## 1. Variables & Domain Modeling
1975
+
1976
+ ### Semi-Continuous Decisions ($\{0\} \cup [L, U]$)
1977
+
1978
+ A semi-continuous quantity must be either `0` (inactive) or within a positive interval `[L, U]` (e.g., a production batch with a minimum run size $L = 10$ and capacity $U = 100$).
1979
+
1980
+ Combine a continuous variable `x` with a binary activation variable `isActive` and linking constraints $L \cdot b \le x \le U \cdot b$:
1981
+
1982
+ ```typescript
1983
+ import { Model, expr } from "@ohtaman/washi";
1984
+
1985
+ const m = new Model("SemiContinuousBatch");
1986
+ const batch = m.continuous("batch", { lb: 0, ub: 100 });
1987
+ const isActive = m.binary("is_active");
1988
+
1989
+ // If isActive === 0, batch is forced to 0; if isActive === 1, batch lies in [10, 100]
1990
+ m.constraint(expr`${batch} >= 10 * ${isActive}`, "min_batch_run");
1991
+ m.constraint(expr`${batch} <= 100 * ${isActive}`, "max_batch_run");
1992
+ ```
1993
+
1994
+ ### Minimal-Perturbation Rescheduling vs. Warm Starting
1995
+
1996
+ When re-optimizing an existing schedule after a disruption, passing yesterday's assignment via [`initialValue`](docs/guide/variables.md#primal-initial-values-warm-start-hints) only gives the MIP solver a starting search point—it does **not** prevent the solver from changing every assignment for a tiny objective gain.
1997
+
1998
+ To keep a revised binary plan $x_i \in \{0, 1\}$ close to a baseline assignment $x_i^{\text{prev}} \in \{0, 1\}$, penalize the **Hamming distance** (number of flipped decisions) in the objective:
1999
+
2000
+ $$\text{Changes} = \sum_{i:\, x_i^{\text{prev}} = 0} x_i + \sum_{i:\, x_i^{\text{prev}} = 1} (1 - x_i)$$
2001
+
2002
+ ```typescript
2003
+ import { Model, expr, minimize } from "@ohtaman/washi";
2004
+
2005
+ const m = new Model("StableReschedule");
2006
+ const prevPlan = [1, 0, 1, 0];
2007
+ const x = prevPlan.map((prev, i) =>
2008
+ m.binary(`slot_${i}`, { initialValue: prev })
2009
+ );
2010
+
2011
+ // Linear terms counting how many 0/1 decisions differ from prevPlan
2012
+ const flipTerms = x.map((v, i) => (prevPlan[i] === 1 ? expr`1 - ${v}` : v));
2013
+
2014
+ // Require at least 3 active slots while penalizing changes from prevPlan
2015
+ m.constraint(expr`sum(${x}) >= 3`, "min_active_slots");
2016
+ m.objective(minimize(expr`5 * sum(${flipTerms})`));
2017
+ ```
2018
+
2019
+ ---
2020
+
2021
+ ## 2. Linearizing Non-Linear Expressions
2022
+
2023
+ ### Product of Two Binary Variables ($x_A \cdot x_B$)
2024
+
2025
+ In integer programming, if an interaction cost or synergy bonus applies only when two binary decisions $x_A, x_B \in \{0, 1\}$ are **both** equal to `1`, avoid introducing a quadratic term. Instead, introduce an auxiliary variable $z_{AB} \in [0, 1]$ and add the three standard McCormick / Fortet linear inequalities so the model remains a pure MILP:
2026
+
2027
+ $$z_{AB} \le x_A, \quad z_{AB} \le x_B, \quad z_{AB} \ge x_A + x_B - 1$$
2028
+
2029
+ ```typescript
2030
+ import { Model, expr, maximize } from "@ohtaman/washi";
2031
+
2032
+ const m = new Model("BinaryProductLinearization");
2033
+ const xA = m.binary("xA");
2034
+ const xB = m.binary("xB");
2035
+ // When xA and xB are binary, continuous [0, 1] bounds suffice to force bothAB into {0, 1}
2036
+ const bothAB = m.continuous("both_AB", { lb: 0, ub: 1 });
2037
+
2038
+ m.constraint(expr`${bothAB} <= ${xA}`, "and_upper_A");
2039
+ m.constraint(expr`${bothAB} <= ${xB}`, "and_upper_B");
2040
+ m.constraint(expr`${bothAB} >= ${xA} + ${xB} - 1`, "and_lower_AB");
2041
+
2042
+ m.objective(maximize(expr`10 * ${xA} + 10 * ${xB} + 5 * ${bothAB}`));
2043
+ ```
2044
+
2045
+ ### Min-Max & Max-Min Objectives (Load Balancing)
2046
+
2047
+ Linear and quadratic objectives cannot call `Math.max(...)` directly. To minimize a bottleneck or peak workload $\min \max_i (\text{load}_i)$, introduce an auxiliary ceiling variable $z$, constrain every individual load by $\text{load}_i \le z$, and minimize $z$:
2048
+
2049
+ $$\min z \quad \text{subject to} \quad \text{load}_i \le z \quad \forall i$$
2050
+
2051
+ ```typescript
2052
+ import { Model, expr, minimize } from "@ohtaman/washi";
2053
+
2054
+ const m = new Model("MinMaxLoadBalancing");
2055
+ const workers = ["Alice", "Bob", "Carol"] as const;
2056
+ const hours = m.continuous(workers, { lb: 0, ub: 40 });
2057
+ const maxHours = m.continuous("max_hours", { lb: 0, ub: 40 });
2058
+
2059
+ // Bound every worker's hours by the shared ceiling maxHours
2060
+ m.constraint(workers.map((w) => expr`${hours[w]} <= ${maxHours}`));
2061
+ m.constraint(expr`sum(${hours}) >= 60`, "total_required_work");
2062
+
2063
+ // Minimizing maxHours distributes the 60 hours evenly (20 hours each)
2064
+ m.objective(minimize(maxHours));
2065
+ ```
2066
+
2067
+ *(Symmetrically, to **maximize the minimum** level $\max \min_i u_i$, impose $u_i \ge z$ for all $i$ and set `m.objective(maximize(z))`.)*
2068
+
2069
+ ---
2070
+
2071
+ ## 3. Constraint Design & Numerical Conditioning
2072
+
2073
+ ### Hard vs. Soft Constraints (Elastic Slack Variables)
2074
+
2075
+ Treating every business target as a strict **hard constraint** makes a model `"Infeasible"` as soon as demand exceeds capacity. Keep physical and legal invariants as hard constraints, and model non-rigid targets as **soft constraints** by adding non-negative slack variables ($s \ge 0$) with penalty weights $W$ in the objective:
2076
+
2077
+ | Target Type | Hard Constraint | Soft Constraint ($s \ge 0$) | Objective Penalty Term (Minimize) |
2078
+ | :--- | :--- | :--- | :--- |
2079
+ | **Lower bound** | $\text{lhs} \ge b$ | $\text{lhs} + s_{\text{short}} \ge b$ | $+ W \cdot s_{\text{short}}$ |
2080
+ | **Upper bound** | $\text{lhs} \le b$ | $\text{lhs} - s_{\text{excess}} \le b$ | $+ W \cdot s_{\text{excess}}$ |
2081
+ | **Target equality** | $\text{lhs} = b$ | $\text{lhs} + s_{\text{short}} - s_{\text{excess}} = b$ | $+ W_1 \cdot s_{\text{short}} + W_2 \cdot s_{\text{excess}}$ |
2082
+
2083
+ ```typescript
2084
+ import { Model, expr, minimize } from "@ohtaman/washi";
2085
+
2086
+ const m = new Model("SoftConstraintCoverage");
2087
+ const assignedStaff = m.integer("assigned_staff", { lb: 0, ub: 4 }); // Max 4 staff available
2088
+ const shortage = m.continuous("shortage", { lb: 0 }); // Non-negative slack (s >= 0)
2089
+
2090
+ // Soft target: aim for 5 staff, allowing shortage > 0 if unavailable
2091
+ m.constraint(expr`${assignedStaff} + ${shortage} >= 5`, "target_5_staff");
2092
+
2093
+ // Penalize unmet shortage ($1,000) above regular wage ($100)
2094
+ m.objective(minimize(expr`100 * ${assignedStaff} + 1000 * ${shortage}`));
2095
+
2096
+ await m.solve();
2097
+ // assignedStaff.value === 4, shortage.value === 1 (returns best compromise instead of Infeasible)
1509
2098
  ```
1510
2099
 
1511
- ---
2100
+ > [!TIP]
2101
+ > **Scaling Penalty Weights ($W$)**: Choose $W$ larger than normal operational marginal costs (typically $10\times$ to $1{,}000\times$ unit cost), and avoid extreme constants such as $10^{12}$. Keeping the ratio between the largest and smallest coefficients within $10^5\text{–}10^6$ preserves floating-point numerical stability in LP/MIP solvers.
1512
2102
 
1513
- ## ✅ Independent Solution Validation (`m.validateSolution()`)
2103
+ ### Symmetry Breaking for Identical Resources
1514
2104
 
1515
- 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:
2105
+ When a model contains $K$ interchangeable resources—such as identical trucks, bins, or parallel machines—any feasible assignment has up to $K!$ equivalent permutations with the exact same objective value. Without symmetry breaking, a MIP solver explores redundant mirror-image subtrees before proving optimality.
1516
2106
 
1517
- ```typescript
1518
- const status = await m.solve();
2107
+ Impose a canonical ordering ($u_0 \ge u_1 \ge \dots \ge u_{K-1}$) so identical resources are activated in index order:
1519
2108
 
1520
- if (m.hasPrimalSolution) {
1521
- const report = m.validateSolution({
1522
- feasibilityTolerance: 1e-6, // Max allowed violation for bounds and linear constraints
1523
- integralityTolerance: 1e-6, // Max distance from nearest integer for Integer/Binary vars
1524
- evaluateLogicalConstraints: true,
1525
- });
2109
+ ```typescript
2110
+ import { Model, expr } from "@ohtaman/washi";
1526
2111
 
1527
- console.log("Valid:", report.valid);
1528
- console.log("Complete:", report.complete);
1529
- console.log("Max violation:", report.maxViolation);
1530
- console.log("Summary:", report.summary);
2112
+ const m = new Model("SymmetryBreaking");
2113
+ const numTrucks = 4;
2114
+ const truckUsed = m.binary(numTrucks, { prefix: "truck_used" });
1531
2115
 
1532
- if (!report.valid) {
1533
- console.warn("Violations detected:", report.violations);
1534
- }
2116
+ // Only allow activating truck k+1 when truck k is also active
2117
+ for (let k = 0; k < numTrucks - 1; k++) {
2118
+ m.constraint(
2119
+ expr`${truckUsed[k]} >= ${truckUsed[k + 1]}`,
2120
+ `order_truck_${k}`
2121
+ );
1535
2122
  }
1536
2123
  ```
1537
2124
 
1538
- ### `SolutionValidationReport` Summary
2125
+ ---
1539
2126
 
1540
- | Property | Type | Description |
1541
- | :--- | :--- | :--- |
1542
- | `valid` | `boolean` | `true` iff `complete === true`, `uncheckedConstraints.length === 0`, and `violations.length === 0`. |
1543
- | `complete` | `boolean` | `true` when all variables, Boolean variables, linear constraints, and logical constraints were evaluated. |
1544
- | `maxViolation` | `number` | Largest numeric violation magnitude across all evaluated bounds, integrality checks, and constraints. |
1545
- | `violations` | `SolutionViolation[]` | Flat list of violations (`"missing_value"`, `"non_finite_value"`, `"invalid_boolean_value"`, `"lower_bound"`, `"upper_bound"`, `"integrality"`, `"linear_constraint"`, `"logical_constraint"`, `"unchecked_constraint"`). |
1546
- | `violationsByName` | `Record<string, SolutionViolation[]>` | Violations grouped by variable or constraint name. |
1547
- | `uncheckedConstraints` | `string[]` | Names of constraints that could not be evaluated. |
1548
- | `missingVariables` | `string[]` | Names of numeric or Boolean variables whose values were `null` or `undefined`. |
1549
- | `summary` | `SolutionValidationSummary` | Counts of checked vs. total variables, Boolean variables, linear constraints, and logical constraints. |
2127
+ ## 4. Multi-Objective & MIP Stopping Design
1550
2128
 
1551
- ---
2129
+ ### Weighted Sum vs. Lexicographic Two-Stage Optimization
1552
2130
 
1553
- ## 📄 Inspecting the LP Formulation (`m.toLpString()`)
2131
+ When balancing competing goals—such as minimizing total cost ($f_1$) while also minimizing peak workload ($f_2$)—two approaches are standard:
1554
2132
 
1555
- When debugging coefficients, signs, or Big-M indicator expansions, call `m.toLpString()` to export the model in standard CPLEX/HiGHS `.lp` format:
2133
+ - **Weighted Sum (Single Solve)**: Combine normalized terms into one objective, e.g., ``minimize(expr`${totalCost} + 50 * ${maxHours}`)``.
2134
+ - **Lexicographic (Hierarchical) Two-Stage Solve**: When the primary objective has strict priority, solve for $f_1$ first, add a constraint locking $f_1$ within an acceptable tolerance of its optimum $f_1^*$, and re-solve for $f_2$:
1556
2135
 
1557
2136
  ```typescript
1558
- const m = new Model("DebugModel");
1559
- const x = m.integer("x", { lb: 0, ub: 10 });
1560
- m.objective(maximize(expr`5 * ${x}`));
1561
- m.constraint(expr`2 * ${x} <= 15`, "resource_limit");
2137
+ import { Model, expr, minimize } from "@ohtaman/washi";
1562
2138
 
1563
- console.log(m.toLpString());
1564
- /*
1565
- Maximize
1566
- obj: 5 x
1567
- Subject To
1568
- resource_limit: 2 x <= 15
1569
- Bounds
1570
- 0 <= x <= 10
1571
- Generals
1572
- x
1573
- End
1574
- */
2139
+ const m = new Model("LexicographicTwoStage");
2140
+ const workers = ["Alice", "Bob"] as const;
2141
+ const wage = { Alice: 20, Bob: 20 };
2142
+ const hours = m.continuous(workers, { lb: 0, ub: 40 });
2143
+ const maxHours = m.continuous("max_hours", { lb: 0, ub: 40 });
2144
+
2145
+ const totalCost = expr`dot(${wage}, ${hours})`;
2146
+ m.constraint(expr`sum(${hours}) >= 40`, "required_hours");
2147
+ m.constraint(workers.map((w) => expr`${hours[w]} <= ${maxHours}`));
2148
+
2149
+ // Stage 1: Minimize primary objective (total wage cost)
2150
+ m.objective(minimize(totalCost));
2151
+ await m.solve();
2152
+ const minCost = m.objectiveValue!;
2153
+
2154
+ // Stage 2: Keep cost within 1% of minCost and balance workload (minimize maxHours)
2155
+ m.constraint(expr`${totalCost} <= ${minCost * 1.01}`, "near_optimal_cost");
2156
+ m.objective(minimize(maxHours));
2157
+ await m.solve();
1575
2158
  ```
1576
2159
 
1577
- ---
2160
+ ### MIP Tail-Off & Optimality Gap Tolerances
1578
2161
 
1579
- ## 🧪 Unit Testing Application Logic with `MockSolver`
2162
+ In combinatorial MIP problems, solvers often find a near-optimal feasible solution within seconds, then spend most of the remaining runtime closing the final fraction of a percent in the branch-and-bound tree (**tail-off**).
1580
2163
 
1581
- 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:
2164
+ In interactive UIs and batch pipelines, combine `timeLimit` with business-meaningful tolerances in [`m.solve(options)`](docs/guide/objectives.md#solve-options-solveoptions):
2165
+ - **`relGap: 0.01`**: Stop immediately once the incumbent is proven within **1%** of the theoretical dual bound.
2166
+ - **`absGap: 100`**: Stop immediately once `|objectiveValue - objectiveBound| <= 100` in the objective's native units.
1582
2167
 
1583
- ```typescript
1584
- import { Model, MockSolver, expr, maximize } from "@ohtaman/washi";
2168
+ ---
1585
2169
 
1586
- const mock = new MockSolver({
1587
- status: "Optimal",
1588
- objectiveValue: 420,
1589
- values: { x: 10, y: 20 },
1590
- });
2170
+ ## 5. Decomposition Formulation Tips
1591
2171
 
1592
- const m = new Model({ solver: mock });
1593
- const x = m.integer("x");
1594
- const y = m.integer("y");
1595
- m.objective(maximize(expr`10 * ${x} + 16 * ${y}`));
2172
+ When using [`solveColumnGeneration` or `solveBenders`](docs/guide/decomposition.md):
1596
2173
 
1597
- await m.solve();
1598
- console.log(m.objectiveValue); // 420
1599
- console.log(x.value, y.value); // 10, 20
1600
- ```
2174
+ 1. **Set Covering (`>= 1`) vs. Set Partitioning (`== 1`) in Column Generation**:
2175
+ Formulating coverage constraints as inequalities (`>= 1`) rather than equalities (`== 1`) restricts dual shadow prices to $\pi_i \ge 0$, reducing dual oscillations and accelerating convergence whenever over-coverage can be pruned at no extra cost (e.g., Euclidean routing under the triangle inequality).
2176
+ 2. **Price-and-Branch (`finalizeAsMIP: true`) vs. Exact Branch-and-Price**:
2177
+ Solving the final Restricted Master Problem as a MIP over columns generated at the root LP relaxation (`finalizeAsMIP: true`) implements the **Price-and-Branch** heuristic—avoiding complex branching inside the pricing subproblem while producing high-quality integer solutions in practice.
2178
+ 3. **Multi-Cut Separation in Benders / Lazy Constraints**:
2179
+ When a separation callback detects multiple disconnected subtours or violated components in a candidate solution, returning cuts for **all** violated components in one batch reduces the number of master MIP re-solves (see [Traveling Salesperson (`examples/traveling-salesperson`)](docs/examples/traveling-salesperson.md)).
1601
2180
 
1602
2181
  ---
1603
2182
 
@@ -1611,9 +2190,9 @@ WaSHI is designed around a pluggable, decoupled solver architecture. You formula
1611
2190
 
1612
2191
  | Solver | Engine License | Problem Classes | Best For | Status in WaSHI |
1613
2192
  | :--- | :--- | :--- | :--- | :--- |
1614
- | **HiGHS** | **MIT** | **LP, MIP, Convex QP** | Industrial-scale optimization, operations research, finance | **Built-in default** (zero configuration) |
1615
- | **Z3** | **MIT** | **SMT, SAT, Logic, MIP** | Propositional logic, conflict diagnosis (`unsatCore`), rule engines | Optional plugin (`@ohtaman/washi/solvers/z3`) |
1616
- | **GLPK** | **GPL-3.0** | **LP, MIP** | Academic benchmarks, legacy GLPK compatibility | Optional plugin (`@ohtaman/washi/solvers/glpk`) |
2193
+ | **[HiGHS](https://github.com/ERGO-Code/HiGHS)** | **MIT** | **LP, MIP, Convex QP** | Industrial-scale optimization, operations research, finance | **Built-in default** (zero configuration) |
2194
+ | **[Z3](https://github.com/Z3Prover/z3)** (via [`z3-solver`](https://www.npmjs.com/package/z3-solver)) | **MIT** | **SMT, SAT, Logic, MIP** | Propositional logic, conflict diagnosis (`unsatCore`), rule engines | Optional plugin (`@ohtaman/washi/solvers/z3`) |
2195
+ | **[GLPK](https://www.gnu.org/software/glpk/)** (via [`glpk.js`](https://www.npmjs.com/package/glpk.js)) | **GPL-3.0** | **LP, MIP** | Academic benchmarks, legacy GLPK compatibility | Optional plugin (`@ohtaman/washi/solvers/glpk`) |
1617
2196
  | **Mock** | **MIT** | *Any* | Ultra-fast unit testing in CI without loading WebAssembly | Built-in |
1618
2197
 
1619
2198
  ---
@@ -1639,7 +2218,7 @@ const m3 = new Model({ solver: "glpk" });
1639
2218
 
1640
2219
  ## Solver Guides
1641
2220
 
1642
- - [HiGHS Guide](docs/solvers/highs.md): Web Worker multithreading, MIP tolerances, and QP solving.
2221
+ - [HiGHS Guide](docs/solvers/highs.md): Background Web Worker execution, MIP tolerances, and Convex QP solving.
1643
2222
  - [Z3 Guide](docs/solvers/z3.md): SMT assertions, logic modeling, and Unsat Core analysis.
1644
2223
  - [GLPK Guide](docs/solvers/glpk.md): GLPK adapter, GPL-3.0 licensing considerations, and browser usage.
1645
2224
  - [Custom Solvers](docs/solvers/custom-solver.md): Step-by-step developer tutorial for integrating proprietary or cloud REST solvers.
@@ -1648,17 +2227,39 @@ const m3 = new Model({ solver: "glpk" });
1648
2227
 
1649
2228
  # HiGHS Solver Engine
1650
2229
 
1651
- **HiGHS** is high-performance mathematical optimization software developed at the University of Edinburgh. 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).
2231
+
2232
+ In `@ohtaman/washi`, HiGHS runs entirely in WebAssembly compiled directly from official upstream source ([ERGO-Code/HiGHS](https://github.com/ERGO-Code/HiGHS)) using its native C API (`highs_c_api.h`), providing zero-config execution across Node.js, Web Workers, and browsers without third-party wrapper dependencies.
1652
2233
 
1653
2234
  ---
1654
2235
 
1655
2236
  ## Key Features in WaSHI
1656
2237
 
1657
- - **Built-in & Zero-Config**: Bundled directly with `@ohtaman/washi`. No separate peer dependency or compilation required.
2238
+ - **Built-in & Zero-Config**: Bundled directly with `@ohtaman/washi` with zero external runtime dependencies. No separate peer dependency or C++ compiler required.
1658
2239
  - **Permissive MIT License**: 100% commercially friendly.
1659
- - **Web Worker 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.
1660
2241
  - **Warm-Start Support (`initialValue`)**: Supports passing variable primal initial value hints (`initialValue`) to HiGHS `setSolution` for warm starting.
1661
- - **Fast WASM Execution**: Emscripten-compiled 32-bit WASM runs at near-native speed.
2242
+ - **Fast WASM Execution**: Runs the directly compiled 32-bit WebAssembly binary at near-native speed.
2243
+
2244
+ ---
2245
+
2246
+ ## Architecture & WebAssembly Runtime
2247
+
2248
+ WaSHI compiles official HiGHS C++ source directly to WebAssembly (`highs.wasm`) with `-fwasm-exceptions` and single-threaded universal portability (`-DHIGHS_NO_DEFAULT_THREADS=ON`). By interfacing directly with HiGHS's native C API (`highs_c_api.h`), WaSHI achieves zero-copy solution extraction via typed array views on WebAssembly linear memory, rapid upstream release tracking, and a self-contained zero-dependency distribution.
2249
+
2250
+ ### Callbacks & Solver Interactivity
2251
+
2252
+ - **Progress Monitoring & Cancellation (Supported)**:
2253
+ Through HiGHS's internal logging and interrupt hooks, WaSHI provides real-time progress callbacks (`onProgress`) and cooperative mid-solve cancellation via standard `AbortSignal`.
2254
+ - **Dynamic Cuts / Lazy Constraints (Not Exposed in WASM Driver)**:
2255
+ WaSHI's HiGHS WebAssembly C API driver currently binds logging, progress, interrupt, and incumbent callbacks and does not expose in-tree lazy constraint or user cut callbacks across the WASM-to-JS boundary. For problems requiring dynamic subtour elimination or Benders decomposition, use [`await m.solveBenders({ subproblem })`](docs/guide/decomposition.md#benders-decomposition-solvebenders) (or a manual outer cutting-plane loop: solve $\to$ detect violation $\to$ `model.constraint(...)` $\to$ re-solve), which iteratively separates cuts and converges to the same exact optimal solution.
2256
+
2257
+ ### WebAssembly Asset Lifecycle & On-Demand Builds
2258
+
2259
+ HiGHS's WebAssembly binary (`public/highs.wasm`) and runtime driver (`runtime.js`) are managed via `highs.config.json` with cryptographic SHA-256 verification:
2260
+ - **Zero-Friction Verification**: Build and distribution pipelines run `node scripts/ensure-highs-wasm.mjs`, verifying local assets in milliseconds before bundling.
2261
+ - **On-Demand Release Download**: When assets are missing or updated, `ensure-highs-wasm.mjs` automatically downloads verified binaries from WaSHI's GitHub Release assets.
2262
+ - **Compiling from Source**: When updating HiGHS or modifying compiler flags, run `npm run build:highs-wasm` with the Emscripten SDK (`emcc`), CMake, and Ninja installed, and upload the rebuilt assets to the `highs-<version>` GitHub Release tag (`gh release upload highs-v1.15.1 public/highs.wasm src/modeler/solvers/highs/runtime.js --clobber`) so on-demand downloads match `highs.config.json` (or run `.github/workflows/build-wasm.yml` and commit the `highs-wasm-assets` artifact including `highs.config.json`).
1662
2263
 
1663
2264
  ---
1664
2265
 
@@ -1686,7 +2287,7 @@ const count = model.integer("count", { lb: 0, ub: 50 });
1686
2287
  ```
1687
2288
 
1688
2289
  ### 3. Convex Quadratic Programming (QP)
1689
- Solves quadratic objective functions subject to linear constraints:
2290
+ Solves continuous models with convex quadratic objective functions subject to linear constraints:
1690
2291
 
1691
2292
  ```typescript
1692
2293
  // Minimize risk/variance uniformly using expr:
@@ -1697,6 +2298,9 @@ model.objective(minimize(expr`0.5 * ${x}^2 + 0.5 * ${y}^2`));
1697
2298
  // model.objective(minimize(quadObj));
1698
2299
  ```
1699
2300
 
2301
+ > [!NOTE]
2302
+ > **Continuous Convex QP Only**: HiGHS solves Convex QP models where **all** model variables are continuous (`m.continuous`), with a positive semi-definite quadratic matrix $\mathbf{Q}$ when minimizing (or negative semi-definite when maximizing). Mixed-Integer Quadratic Programming (**MIQP**—any model combining a quadratic objective with `Integer`, `Binary`, or `BoolVar` variables) and quadratic constraints (**QCP**) are not supported; to model products of binary decisions in MILP, see [Formulation Patterns & Tips](docs/guide/formulation-tips.md).
2303
+
1700
2304
  ---
1701
2305
 
1702
2306
  ## Solver Options
@@ -1717,6 +2321,14 @@ await model.solve({
1717
2321
  console.log(`Elapsed: ${ev.elapsedTime.toFixed(1)}s, Best: ${ev.objectiveValue}`);
1718
2322
  },
1719
2323
  });
2324
+
2325
+ // Always check hasPrimalSolution when timeLimit or AbortSignal is used:
2326
+ if (model.hasPrimalSolution) {
2327
+ console.log(
2328
+ `Usable solution (${model.status}, termination: ${model.terminationStatus}):`,
2329
+ `obj = ${model.objectiveValue}, gap = ${((model.relGap ?? 0) * 100).toFixed(2)}%`
2330
+ );
2331
+ }
1720
2332
  ```
1721
2333
 
1722
2334
  ---
@@ -1741,6 +2353,16 @@ const syncModel = new Model({
1741
2353
 
1742
2354
  ---
1743
2355
 
2356
+ ## References & Upstream Links
2357
+
2358
+ - **Official Website**: [https://highs.dev/](https://highs.dev/)
2359
+ - **Official Documentation**: [https://ergo-code.github.io/HiGHS/](https://ergo-code.github.io/HiGHS/)
2360
+ - **Source Repository**: [ERGO-Code/HiGHS](https://github.com/ERGO-Code/HiGHS) (MIT License)
2361
+ - **Academic Citation**: If you cite HiGHS in academic or technical publications, please reference the foundational paper requested by the HiGHS authors:
2362
+ > Q. Huangfu and J. A. J. Hall, *"Parallelizing the dual revised simplex method"*, Mathematical Programming Computation, 10 (1), 119–142, 2018. DOI: [10.1007/s12532-017-0130-5](https://doi.org/10.1007/s12532-017-0130-5)
2363
+
2364
+ ---
2365
+
1744
2366
  # Z3 SMT Solver Engine
1745
2367
 
1746
2368
  **Z3** is a theorem prover and Satisfiability Modulo Theories (SMT) solver developed by Microsoft Research. In WaSHI, Z3 provides advanced propositional logic reasoning and conflict diagnosis.
@@ -1766,6 +2388,9 @@ npm install z3-solver
1766
2388
  3. **Exact Mathematical Guarantees**:
1767
2389
  Z3 can perform exact rational arithmetic without floating-point inaccuracies.
1768
2390
 
2391
+ > [!TIP]
2392
+ > **Z3 (`m.check()`) vs. HiGHS (`m.solve()`)**: Z3 excels at satisfiability checking (`m.check()`), formal policy verification, general inequality implications (`` implies(expr`${x} <= 50`, y) ``), and scale-invariant `unsatCore` extraction. For numerical LP, MIP, or Convex QP optimization with objective functions over tens or hundreds of variables, use the built-in **HiGHS** solver (`m.solve()`), whose simplex relaxations and branch-and-bound cutting planes are orders of magnitude faster than `Z3.Optimize`. Or combine both in a hybrid pipeline: verify/diagnose rules with Z3 `m.check()`, then optimize costs with HiGHS `m.solve()`.
2393
+
1769
2394
  ---
1770
2395
 
1771
2396
  ## Basic Usage
@@ -2010,7 +2635,7 @@ export interface SolveModelData extends CheckModelData {
2010
2635
 
2011
2636
  Custom solvers declare their supported features using `capabilities`. Before dispatching `solve()` or `check()`, `Model` validates:
2012
2637
  - `supportsQP === false` when the objective contains quadratic terms (`QuadExpr`),
2013
- - `supportsSMT !== true` when the model contains logical constraints (`m.logicalConstraints`), and
2638
+ - when the model contains logical constraints (`m.logicalConstraints`) and the solver does not support SMT (`supportsSMT !== true`), `Model` automatically linearizes them into equivalent 0-1 MIP binary variables and linear constraints via `linearizeModelForMip`, and
2014
2639
  - `supportsWarmStart !== true` when any variable has a primal `initialValue` set.
2015
2640
 
2016
2641
  ```typescript
@@ -2419,21 +3044,23 @@ describe("Custom Solver Integration", () => {
2419
3044
 
2420
3045
  # Examples & API Finder
2421
3046
 
2422
- Explore runnable optimization and logic formulations built with WaSHI. Every example can be executed directly inside its documentation page, or edited in the [Playground](https://ohtaman.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/).
2423
3048
 
2424
3049
  ## Examples
2425
3050
 
2426
3051
  | Example | Problem Class | Solver | Key WaSHI APIs | Description | Code Editor |
2427
3052
  | :--- | :--- | :--- | :--- | :--- | :--- |
2428
- | **[Production Planning](docs/examples/production.md)** | LP / MIP | HiGHS | `m.integer()`, `m.constraint()`, `expr` | Basic variables, tagged template literal equations, and maximizing profit under resource limits. | [Playground](https://ohtaman.github.io/washi/demo/?preset=production) |
2429
- | **[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) |
2430
- | **[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) |
2431
- | **[Diet & Sensitivity Analysis](docs/examples/diet-sensitivity.md)** | LP | HiGHS | `constr.dual`, `constr.slack`, `var.reducedCost` | Reading shadow prices (bottlenecks), constraint surpluses, and reduced costs after solving. | [Playground](https://ohtaman.github.io/washi/demo/?preset=diet_sensitivity) |
2432
- | **[Staff Shift Scheduling](docs/examples/shift-scheduling.md)** | Binary MIP + Unsat Core | HiGHS + Z3 | `m.binary(staff, days)`, `check()`, `unsatCore` | 5×7 weekly roster planner (coverage, senior duty, max shifts, day-off requests) and diagnosing conflicting vacation requests with Z3 `unsatCore`. | [Playground](https://ohtaman.github.io/washi/demo/?preset=shift_scheduling) |
2433
- | **[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) |
2434
- | **[Portfolio Optimization](docs/examples/portfolio-qp.md)** | Convex QP | HiGHS | `m.continuous()`, `m.constraint()`, `dot()`, `QuadExpr` | Markowitz mean-variance risk minimization ($w^T \Sigma w$) using `QuadExpr` subject to return targets. | [Playground](https://ohtaman.github.io/washi/demo/?preset=portfolio_qp) |
2435
- | **[SMT Security Policies](docs/examples/smt-security.md)** | SMT Logic | Z3 | `m.bool()`, `m.constraint()`, `m.check()` | Propositional variables (`BoolVar`) and logical connectives (`implies`, `and`, `not`, `xor`) mixed with integer bounds. | [Playground](https://ohtaman.github.io/washi/demo/?preset=smt_sat_scheduling) |
2436
- | **[Conflict Diagnosis (`unsatCore`)](docs/examples/unsat-core.md)** | Unsat Core | Z3 | `m.constraint()`, `m.check()`, `m.unsatCore` | Extracting a conflicting constraint subset (`unsatCore`) when staffing and budget rules contradict each other. | [Playground](https://ohtaman.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) |
3062
+ | **[Vehicle Routing (CVRP)](docs/examples/vehicle-routing.md)** | Combinatorial OR / Dantzig-Wolfe | HiGHS | `m.solveColumnGeneration()`, `pricing` | Dynamic route column generation from customer dual shadow prices to minimize total fleet vehicles. | [Decomposition Guide](docs/guide/decomposition.md) |
3063
+ | **[Traveling Salesperson (TSP)](docs/examples/traveling-salesperson.md)** | Combinatorial OR / Benders | HiGHS | `m.solveBenders()`, `subproblem` | Subtour elimination on degree-assignment relaxation using dynamic Benders feasibility cuts. | [Decomposition Guide](docs/guide/decomposition.md) |
2437
3064
 
2438
3065
  ## Find by WaSHI API (Reverse Index)
2439
3066
 
@@ -2444,13 +3071,15 @@ Looking for a specific modeling method or solver capability? Jump straight to th
2444
3071
  | ``m.constraint(expr`...`)`` | Tagged template literal constraints | [Production Planning](docs/examples/production.md) | [`01-production-planning.ts`](https://github.com/ohtaman/washi/blob/main/examples/node/01-production-planning.ts) |
2445
3072
  | `m.binary(keys)` / `m.continuous(keys)` | 1D dictionary of indexed variables | [0-1 Knapsack](docs/examples/knapsack.md) | [`02-knapsack-problem.ts`](https://github.com/ohtaman/washi/blob/main/examples/node/02-knapsack-problem.ts) |
2446
3073
  | `m.continuous(rows, cols)` / `m.binary(rows, cols)` | 2D matrix of indexed variables | [Transportation Network](docs/examples/transportation.md), [Shift Scheduling](docs/examples/shift-scheduling.md) | [`09-transportation-problem.ts`](https://github.com/ohtaman/washi/blob/main/examples/node/09-transportation-problem.ts) |
2447
- | `dot(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) |
2448
3075
  | `constr.dual` & `constr.slack` | Shadow prices & constraint surpluses | [Diet & Sensitivity Analysis](docs/examples/diet-sensitivity.md) | [`10-diet-sensitivity.ts`](https://github.com/ohtaman/washi/blob/main/examples/node/10-diet-sensitivity.ts) |
2449
3076
  | `var.reducedCost` | Variable reduced costs in LP | [Diet & Sensitivity Analysis](docs/examples/diet-sensitivity.md) | [`10-diet-sensitivity.ts`](https://github.com/ohtaman/washi/blob/main/examples/node/10-diet-sensitivity.ts) |
2450
3077
  | `m.indicator()` | Big-M conditional constraints (`binVar => constr`) | [E-Commerce Coupon Optimizer](docs/examples/ecommerce-coupons.md) | [`12-ecommerce-coupon-optimizer.ts`](https://github.com/ohtaman/washi/blob/main/examples/node/12-ecommerce-coupon-optimizer.ts) |
2451
3078
  | `new QuadExpr()` | Convex quadratic objectives ($x^T Q x$) | [Portfolio Optimization](docs/examples/portfolio-qp.md) | [`03-portfolio-qp.ts`](https://github.com/ohtaman/washi/blob/main/examples/node/03-portfolio-qp.ts) |
2452
- | `m.bool()` & `.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) |
2453
3080
  | `m.check()` & `m.unsatCore` | Feasibility check & conflict diagnosis | [Conflict Diagnosis](docs/examples/unsat-core.md), [Shift Scheduling](docs/examples/shift-scheduling.md) | [`05-unsat-core-diagnosis.ts`](https://github.com/ohtaman/washi/blob/main/examples/node/05-unsat-core-diagnosis.ts) |
3081
+ | `m.solveColumnGeneration()` | Dantzig-Wolfe Column Generation | [Vehicle Routing](docs/examples/vehicle-routing.md) | [`13-vehicle-routing-column-generation.ts`](https://github.com/ohtaman/washi/blob/main/examples/node/13-vehicle-routing-column-generation.ts) |
3082
+ | `m.solveBenders()` | Benders Decomposition & cut separation | [Traveling Salesperson](docs/examples/traveling-salesperson.md) | [`14-traveling-salesperson-benders.ts`](https://github.com/ohtaman/washi/blob/main/examples/node/14-traveling-salesperson-benders.ts) |
2454
3083
  | `solver: "glpk"` | Solving LP/MIP with GLPK plugin | [GLPK Solver Guide](docs/solvers/glpk.md) | [`08-glpk-solver-plugin.ts`](https://github.com/ohtaman/washi/blob/main/examples/node/08-glpk-solver-plugin.ts) |
2455
3084
 
2456
3085
  ## Running Examples Locally in Node.js
@@ -2469,6 +3098,8 @@ npx tsx examples/node/09-transportation-problem.ts
2469
3098
  npx tsx examples/node/10-diet-sensitivity.ts
2470
3099
  npx tsx examples/node/11-shift-scheduling.ts
2471
3100
  npx tsx examples/node/12-ecommerce-coupon-optimizer.ts
3101
+ npx tsx examples/node/13-vehicle-routing-column-generation.ts
3102
+ npx tsx examples/node/14-traveling-salesperson-benders.ts
2472
3103
  ```
2473
3104
 
2474
3105
  ---
@@ -2477,7 +3108,7 @@ npx tsx examples/node/12-ecommerce-coupon-optimizer.ts
2477
3108
 
2478
3109
  | Problem Class | Solver | Key APIs | Links |
2479
3110
  | :--- | :--- | :--- | :--- |
2480
- | Linear / Integer Programming (LP / MIP) | HiGHS (default) | `m.integer()`, `m.constraint()`, `expr` | <ul><li><a href="https://ohtaman.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> |
2481
3112
 
2482
3113
  Production planning is a classic Linear Programming problem: determine the optimal quantity of each product to manufacture in order to maximize total profit while respecting resource constraints (raw materials, machine time, labor hours).
2483
3114
 
@@ -2507,9 +3138,9 @@ async function runProductionOptimization() {
2507
3138
  // Objective: Maximize total profit ($45 per chair, $80 per table)
2508
3139
  m.objective(maximize(expr`45 * ${chairs} + 80 * ${tables}`));
2509
3140
 
2510
- // Resource constraints: declarative formulas via expr and fluent constraint()
2511
- m.constraint(expr`5 * ${chairs} + 20 * ${tables} <= 400`, "mahogany_limit")
2512
- .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");
2513
3144
 
2514
3145
  const status = await m.solve();
2515
3146
 
@@ -2527,7 +3158,7 @@ runProductionOptimization();
2527
3158
 
2528
3159
  | Problem Class | Solver | Key APIs | Links |
2529
3160
  | :--- | :--- | :--- | :--- |
2530
- | 0-1 Binary MIP | HiGHS (default) | `m.binary()`, `m.constraint()`, `expr`, `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> |
2531
3162
 
2532
3163
  The 0-1 Knapsack problem is an essential combinatorial optimization problem: given a set of items, each with a weight and a value, determine which items to include in a collection so that the total weight does not exceed a given limit and the total value is maximized.
2533
3164
 
@@ -2544,7 +3175,7 @@ $$\begin{aligned}
2544
3175
  ## Implementation in WaSHI
2545
3176
 
2546
3177
  ```typescript
2547
- import { Model, maximize, dot, expr } from "@ohtaman/washi";
3178
+ import { Model, maximize, expr } from "@ohtaman/washi";
2548
3179
 
2549
3180
  async function solveKnapsack() {
2550
3181
  const m = new Model("Knapsack");
@@ -2564,17 +3195,18 @@ async function solveKnapsack() {
2564
3195
  const values = Object.fromEntries(items.map((it) => [it.name, it.value]));
2565
3196
  const weights = Object.fromEntries(items.map((it) => [it.name, it.weight]));
2566
3197
 
2567
- // Maximize total value via dot product
2568
- m.objective(maximize(dot(values, x)));
3198
+ // Maximize total value via dot() inside expr
3199
+ m.objective(maximize(expr`dot(${values}, ${x})`));
2569
3200
 
2570
- // Weight capacity constraint using natural expr and constraint() syntax
2571
- 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");
2572
3203
 
2573
3204
  const status = await m.solve();
2574
3205
 
2575
3206
  console.log(`Status: ${status}`);
2576
3207
  console.log(`Max Total Value: $${m.objectiveValue}`);
2577
- 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(", "));
2578
3210
  }
2579
3211
 
2580
3212
  solveKnapsack();
@@ -2586,7 +3218,7 @@ solveKnapsack();
2586
3218
 
2587
3219
  | Problem Class | Solver | Key APIs | Links |
2588
3220
  | :--- | :--- | :--- | :--- |
2589
- | Linear Programming (LP) | HiGHS (default) | `m.continuous()`, `m.constraint()`, `expr`, `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> |
2590
3222
 
2591
3223
  The transportation problem is one of the most fundamental network optimization models: determine how many units to ship from each supply origin (factory) to each destination (store) to minimize total shipping cost while respecting factory supply limits and meeting store demands.
2592
3224
 
@@ -2617,7 +3249,7 @@ $$\begin{aligned}
2617
3249
  ## Implementation in WaSHI
2618
3250
 
2619
3251
  ```typescript
2620
- import { Model, sum, dot, minimize, expr } from "@ohtaman/washi";
3252
+ import { Model, minimize, expr } from "@ohtaman/washi";
2621
3253
 
2622
3254
  const factories = ["Tokyo", "Nagoya", "Osaka"] as const;
2623
3255
  const stores = ["North", "East", "West", "South"] as const;
@@ -2631,51 +3263,78 @@ const shippingCost = {
2631
3263
  const supplies = { Tokyo: 120, Nagoya: 100, Osaka: 80 };
2632
3264
  const demands = { North: 70, East: 90, West: 60, South: 60 };
2633
3265
 
2634
- async function runTransportation() {
3266
+ async function solveScenario(label: string, cap: typeof supplies) {
2635
3267
  const m = new Model("TransportationNetwork");
2636
3268
 
2637
3269
  // 1. Create 2D continuous variables: ship[factory][store] >= 0
2638
3270
  const ship = m.continuous([...factories], [...stores], { lb: 0 });
2639
3271
 
2640
3272
  // 2. Objective: Minimize total shipping cost across all routes via 2D dot product
2641
- m.objective(minimize(dot(shippingCost, ship)));
3273
+ m.objective(minimize(expr`dot(${shippingCost}, ${ship})`));
2642
3274
 
2643
- // 3. Supply constraints (row sums): outgoing from f <= supplies[f]
2644
- for (const f of factories) {
2645
- m.constraint(
2646
- expr`${sum(stores, (s) => ship[f][s])} <= ${supplies[f]}`,
2647
- `supply_${f}`
2648
- );
2649
- }
3275
+ // 3. Supply constraints (row sums): outgoing from f <= cap[f]
3276
+ const supplyConstrs = Object.fromEntries(
3277
+ factories.map((f) => [
3278
+ f,
3279
+ m.constraint(expr`sum(${ship[f]}) <= ${cap[f]}`, `supply_${f}`),
3280
+ ])
3281
+ );
2650
3282
 
2651
3283
  // 4. Demand constraints (column sums): incoming to s >= demands[s]
2652
- for (const s of stores) {
2653
- m.constraint(
2654
- expr`${sum(factories, (f) => ship[f][s])} >= ${demands[s]}`,
2655
- `demand_${s}`
2656
- );
2657
- }
3284
+ const demandConstrs = Object.fromEntries(
3285
+ stores.map((s) => [
3286
+ s,
3287
+ m.constraint(
3288
+ expr`sum(${factories}, ${(f) => ship[f][s]}) >= ${demands[s]}`,
3289
+ `demand_${s}`
3290
+ ),
3291
+ ])
3292
+ );
2658
3293
 
2659
3294
  const status = await m.solve();
2660
- console.log(`Status: ${status}, Total Shipping Cost: $${m.objectiveValue}`);
3295
+ console.log(`[${label}] Status: ${status}, Total Shipping Cost: $${m.objectiveValue}`);
2661
3296
  for (const f of factories) {
3297
+ const dual = supplyConstrs[f].dual ?? 0;
2662
3298
  const routes = stores
2663
3299
  .map((s) => `${s}:${Math.round(ship[f][s].value ?? 0)}`)
2664
3300
  .join(", ");
2665
- console.log(` ${f.padEnd(7)} -> ${routes}`);
3301
+ console.log(` ${f.padEnd(7)} -> ${routes} (supply dual: $${dual}/u)`);
2666
3302
  }
3303
+ const demandDuals = stores
3304
+ .map((s) => `${s}:$${demandConstrs[s].dual ?? 0}`)
3305
+ .join(", ");
3306
+ console.log(` Marginal Delivered Cost (demand duals) -> ${demandDuals}`);
3307
+ }
3308
+
3309
+ async function runTransportation() {
3310
+ // Scenario A: Baseline capacities ($920)
3311
+ await solveScenario("Scenario A: Baseline", supplies);
3312
+
3313
+ // Scenario B: Tokyo capacity reduced (120 -> 80) due to maintenance ($1,000)
3314
+ await solveScenario("Scenario B: Tokyo Maintenance", {
3315
+ Tokyo: 80,
3316
+ Nagoya: 120,
3317
+ Osaka: 100,
3318
+ });
2667
3319
  }
2668
3320
 
2669
3321
  runTransportation();
2670
3322
  ```
2671
3323
 
3324
+ ## Practical Notes
3325
+
3326
+ - **Interpreting Supply & Demand Shadow Prices (`.dual`)**:
3327
+ - **`demand_s.dual` ($\ge 0$)**: The marginal delivered cost of supplying 1 additional unit to store $s$.
3328
+ - **`supply_f.dual` ($\le 0$)**: How much total network shipping cost would decrease if factory $f$'s capacity were expanded by 1 unit. In Scenario B, when Tokyo is constrained at 80 units, `supply_Tokyo.dual` becomes negative (`-$2/u`), indicating that each extra unit of capacity in Tokyo saves \$2 by avoiding rerouting through Nagoya.
3329
+ - **Total Supply vs. Total Demand ($\sum \text{Supply}_f \ge \sum \text{Demand}_s$)**: If store demands exceed aggregate factory capacity, the hard demand constraints become `"Infeasible"`. In production logistics systems, add an elastic "spot-carrier / backorder" variable $u_s \ge 0$ to each store's demand constraint ($\sum_f x_{f,s} + u_s \ge \text{Demand}_s$) with a high penalty cost so the solver allocates scarce inventory optimally rather than failing.
3330
+
2672
3331
  ---
2673
3332
 
2674
3333
  # Diet Problem & Sensitivity Analysis (LP Duals & Slacks)
2675
3334
 
2676
3335
  | Problem Class | Solver | Key APIs | Links |
2677
3336
  | :--- | :--- | :--- | :--- |
2678
- | Linear Programming (LP) | HiGHS (default) | `m.continuous()`, `m.constraint()`, `expr`, `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> |
2679
3338
 
2680
3339
  When solving a Linear Programming model, the optimal variable values (`var.value`) are only half the story. In practice, decision-makers need to know:
2681
3340
 
@@ -2699,7 +3358,7 @@ We choose daily servings ($0 \le x_i \le 4$) among 6 food items to meet 4 daily
2699
3358
  ## Implementation in WaSHI
2700
3359
 
2701
3360
  ```typescript
2702
- import { Model, minimize, dot, expr } from "@ohtaman/washi";
3361
+ import { Model, minimize, expr } from "@ohtaman/washi";
2703
3362
 
2704
3363
  const foods = [
2705
3364
  { name: "Oats", cost: 0.60, calories: 150, protein: 5, calcium: 20, iron: 1.8 },
@@ -2722,14 +3381,16 @@ async function runDietSensitivity() {
2722
3381
  const calcs = Object.fromEntries(foods.map((f) => [f.name, f.calcium]));
2723
3382
  const irons = Object.fromEntries(foods.map((f) => [f.name, f.iron]));
2724
3383
 
2725
- // Objective: Minimize total daily cost via dot product
2726
- m.objective(minimize(dot(costs, srv)));
3384
+ // Objective: Minimize total daily cost via dot() inside expr
3385
+ m.objective(minimize(expr`dot(${costs}, ${srv})`));
2727
3386
 
2728
3387
  // Nutritional constraints defined declaratively with expr and constraint()
2729
- m.constraint(expr`${dot(cals, srv)} >= 2000`, "min_calories")
2730
- .constraint(expr`${dot(prots, srv)} >= 105`, "min_protein")
2731
- .constraint(expr`${dot(calcs, srv)} >= 1000`, "min_calcium")
2732
- .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
+ ];
2733
3394
 
2734
3395
  const status = await m.solve();
2735
3396
 
@@ -2740,11 +3401,8 @@ async function runDietSensitivity() {
2740
3401
  console.log(` ${f.name.padEnd(8)}: value=${v.value?.toFixed(2)}, reducedCost=${v.reducedCost?.toFixed(4)}`);
2741
3402
  }
2742
3403
  console.log("--- Nutritional Constraints (Dual & Slack) ---");
2743
- for (const name of ["min_calories", "min_protein", "min_calcium", "min_iron"]) {
2744
- const c = m.getConstraintByName(name);
2745
- if (c) {
2746
- console.log(` ${c.name.padEnd(13)}: dual=$${c.dual?.toFixed(4)}, slack=${Math.abs(c.slack ?? 0).toFixed(2)}`);
2747
- }
3404
+ for (const c of nutConstraints) {
3405
+ console.log(` ${c.name.padEnd(13)}: dual=$${c.dual?.toFixed(4)}, slack=${Math.abs(c.slack ?? 0).toFixed(2)}`);
2748
3406
  }
2749
3407
  }
2750
3408
 
@@ -2755,7 +3413,9 @@ runDietSensitivity();
2755
3413
 
2756
3414
  - **Bottleneck constraints (`slack == 0`, `dual > 0`):** Calories (`2000 kcal`) and Iron (`18 mg`) are tight at their lower bounds. Increasing the daily Iron requirement by 1 mg increases the minimum daily diet cost by **$0.3241**.
2757
3415
  - **Constraints with surplus (`slack > 0`, `dual == 0`):** Protein (`+46.46 g` surplus) and Calcium (`+483.02 mg` surplus) are already exceeded with room to spare, so their shadow prices are `$0`.
2758
- - **Reduced cost (`Salmon`):** Unselected `Salmon` (`0.00` servings) has a reduced cost of `+1.5125`, meaning its price per serving would need to drop by at least **$1.51** before it enters the optimal daily menu.
3416
+ - **Reduced cost at lower bound (`Salmon`):** Unselected `Salmon` (`0.00` servings) has a reduced cost of `+1.5125`, meaning its price per serving would need to drop by at least **$1.51** before it enters the optimal daily menu.
3417
+ - **Negative reduced cost at upper bound (`Oats`, `Eggs`):** Both `Oats` (`reducedCost = -0.2581`) and `Eggs` (`reducedCost = -0.0887`) are pinned against their upper serving cap (`ub = 4.0`). In a minimization LP, a negative reduced cost on an upper-bounded variable tells you how much the total objective would **decrease** per unit increase in `ub`—raising the daily cap on `Oats` from 4 to 5 servings would save **$0.26/day**.
3418
+ - **Basis stability range:** Shadow prices (`dual`) and reduced costs (`reducedCost`) represent local marginal rates of change within the current optimal simplex basis. If a right-hand side or bound changes enough to trigger a basis change (for example, once another variable hits its bound), the marginal rate shifts to a new piece of the piecewise-linear optimal cost curve.
2759
3419
 
2760
3420
  ---
2761
3421
 
@@ -2763,7 +3423,7 @@ runDietSensitivity();
2763
3423
 
2764
3424
  | Problem Class | Solver | Key APIs | Links |
2765
3425
  | :--- | :--- | :--- | :--- |
2766
- | Binary MIP & SMT Unsat Core | HiGHS + Z3 | `m.binary(staff, days)`, `m.constraint()`, `expr`, `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> |
2767
3427
 
2768
3428
  Staff shift scheduling is a classic real-world application of Mixed-Integer Programming in SaaS and workforce management tools. However, in real applications, **staff vacation requests frequently collide with minimum coverage or leadership rules**, causing the solver to return `Infeasible`.
2769
3429
 
@@ -2792,7 +3452,7 @@ We evaluate two distinct, named scenarios with different vacation inputs to illu
2792
3452
  ## Implementation in WaSHI
2793
3453
 
2794
3454
  ```typescript
2795
- import { Model, sum, dot, minimize, expr } from "@ohtaman/washi";
3455
+ import { Model, minimize, expr } from "@ohtaman/washi";
2796
3456
  import "@ohtaman/washi/solvers/z3";
2797
3457
 
2798
3458
  const days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] as const;
@@ -2825,12 +3485,12 @@ function buildShiftModel(name: string, dayOffRequests: Array<{ staff: string; da
2825
3485
  ),
2826
3486
  ])
2827
3487
  );
2828
- m.objective(minimize(dot(costMatrix, shift)));
3488
+ m.objective(minimize(expr`dot(${costMatrix}, ${shift})`));
2829
3489
 
2830
3490
  // Rule 1: Daily headcount coverage
2831
3491
  for (const d of days) {
2832
3492
  m.constraint(
2833
- expr`${sum(staffList, (s) => shift[s.name][d])} >= ${requiredCoverage[d]}`,
3493
+ expr`sum(${staffList}, ${(s) => shift[s.name][d]}) >= ${requiredCoverage[d]}`,
2834
3494
  `coverage_${d}`
2835
3495
  );
2836
3496
  }
@@ -2839,7 +3499,7 @@ function buildShiftModel(name: string, dayOffRequests: Array<{ staff: string; da
2839
3499
  const seniors = staffList.filter((s) => s.isSenior);
2840
3500
  for (const d of days) {
2841
3501
  m.constraint(
2842
- expr`${sum(seniors, (s) => shift[s.name][d])} >= 1`,
3502
+ expr`sum(${seniors}, ${(s) => shift[s.name][d]}) >= 1`,
2843
3503
  `senior_duty_${d}`
2844
3504
  );
2845
3505
  }
@@ -2847,7 +3507,7 @@ function buildShiftModel(name: string, dayOffRequests: Array<{ staff: string; da
2847
3507
  // Rule 3: Max working days per staff member
2848
3508
  for (const s of staffList) {
2849
3509
  m.constraint(
2850
- expr`${sum(days, (d) => shift[s.name][d])} <= ${s.maxShifts}`,
3510
+ expr`sum(${shift[s.name]}) <= ${s.maxShifts}`,
2851
3511
  `max_shifts_${s.name}`
2852
3512
  );
2853
3513
  }
@@ -2883,7 +3543,7 @@ async function runScenario(
2883
3543
  if (status === "Optimal") {
2884
3544
  console.log(`Optimal Weekly Payroll: $${model.objectiveValue}`);
2885
3545
  for (const s of staffList) {
2886
- const schedule = days.map((d) => (Math.round(shift[s.name][d].value ?? 0) === 1 ? "ON" : " ·"));
3546
+ const schedule = days.map((d) => (shift[s.name][d].value === 1 ? "ON" : " ·"));
2887
3547
  console.log(` ${s.name.padEnd(8)}: ${schedule.join(" ")}`);
2888
3548
  }
2889
3549
  console.log("Schedule resolved successfully (no UNSAT diagnosis needed).\n");
@@ -2929,13 +3589,26 @@ async function main() {
2929
3589
  main();
2930
3590
  ```
2931
3591
 
3592
+ ## Auto-Repairing Conflicts with Soft Constraints (Elastic Requests)
3593
+
3594
+ Once Z3 `unsatCore` diagnoses that `senior_duty_Sat` conflicts with `day_off_Alice_Sat` and `day_off_Bob_Sat`, you can offer managers an automatic repair option by relaxing day-off requests from hard constraints (`shift[staff][day] == 0`) into **soft preferences** penalized in the objective (e.g., `+$500` per overridden day off):
3595
+
3596
+ ```typescript
3597
+ // Soft constraint pattern: penalize working on a requested day off by $500
3598
+ // instead of enforcing hard shift[req.staff][req.day] == 0
3599
+ const requestedShiftVars = dayOffRequests.map((req) => shift[req.staff][req.day]);
3600
+ m.objective(minimize(expr`dot(${costMatrix}, ${shift}) + 500 * sum(${requestedShiftVars})`));
3601
+ ```
3602
+
3603
+ With this soft formulation on Scenario B, HiGHS automatically finds a valid roster that honors 3 of the 4 requests (`Alice (Sat)`, `Charlie (Sat)`, and `Diana (Wed)`) and overrides only `Bob (Sat)`—choosing Bob over Alice because Bob's daily wage (`$210`) is `$10` lower than Alice's (`$220`).
3604
+
2932
3605
  ---
2933
3606
 
2934
3607
  # E-Commerce Cart & Coupon Optimizer (Indicator MIP)
2935
3608
 
2936
3609
  | Problem Class | Solver | Key APIs | Links |
2937
3610
  | :--- | :--- | :--- | :--- |
2938
- | Mixed-Integer Programming (MIP) | HiGHS (default) | `m.binary()`, `m.constraint()`, `m.indicator()`, `expr`, `minimize` | <ul><li><a href="https://ohtaman.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> |
2939
3612
 
2940
3613
  Modern e-commerce checkouts often feature complex promotional rules: minimum spend thresholds, category-specific discounts, stackable vs. mutually exclusive coupons, and maximum coupon limits per order. Running an optimizer directly in the browser (or an edge worker) lets the checkout UI automatically apply the best coupon combination—and even recommend a small "filler" item when crossing a spend threshold lowers the customer's net bill.
2941
3614
 
@@ -2960,7 +3633,7 @@ By adding the `$10` cable (`$252` gross), the customer unlocks `MEGA45_250` (`-$
2960
3633
  ## Implementation in WaSHI
2961
3634
 
2962
3635
  ```typescript
2963
- import { Model, sum, minimize, expr } from "@ohtaman/washi";
3636
+ import { Model, minimize, expr } from "@ohtaman/washi";
2964
3637
 
2965
3638
  async function optimizeCheckoutCart() {
2966
3639
  const m = new Model("CartCouponOptimizer");
@@ -2981,50 +3654,43 @@ async function optimizeCheckoutCart() {
2981
3654
 
2982
3655
  // Binary decision variables for each coupon
2983
3656
  const useCoupon = m.binary(coupons.map((c) => c.id));
3657
+ const discounts = Object.fromEntries(coupons.map((c) => [c.id, c.discount]));
2984
3658
 
2985
- const merchSpend = addUsbCable.mul(usbCablePrice).add(baseCartSubtotal);
2986
- const techSpend = addUsbCable.mul(usbCablePrice).add(baseTechSubtotal);
3659
+ const merchSpend = expr`${baseCartSubtotal} + ${usbCablePrice} * ${addUsbCable}`;
3660
+ const techSpend = expr`${baseTechSubtotal} + ${usbCablePrice} * ${addUsbCable}`;
2987
3661
 
2988
- // 1. Indicator constraints: If useCoupon[id] == 1, minimum spend threshold must hold
3662
+ // 1. Indicator constraints: If useCoupon[id] == 1, minimum spend threshold must hold.
3663
+ // Omitting `M` lets WaSHI automatically derive the tightest Big-M from variable bounds!
2989
3664
  for (const cpn of coupons) {
2990
3665
  if (cpn.minCart > 0) {
2991
- m.indicator(useCoupon[cpn.id], merchSpend.ge(cpn.minCart), {
3666
+ m.indicator(useCoupon[cpn.id], expr`${merchSpend} >= ${cpn.minCart}`, {
2992
3667
  activeValue: 1,
2993
- M: 500,
2994
3668
  name: `min_spend_${cpn.id}`,
2995
3669
  });
2996
3670
  }
2997
3671
  if (cpn.minTech > 0) {
2998
- m.indicator(useCoupon[cpn.id], techSpend.ge(cpn.minTech), {
3672
+ m.indicator(useCoupon[cpn.id], expr`${techSpend} >= ${cpn.minTech}`, {
2999
3673
  activeValue: 1,
3000
- M: 500,
3001
3674
  name: `min_tech_${cpn.id}`,
3002
3675
  });
3003
3676
  }
3004
3677
  }
3005
3678
 
3006
3679
  // 2. Mutual exclusion among non-stackable ("Exclusive") coupons
3007
- const exclusiveCoupons = coupons.filter((c) => !c.stackable);
3008
- m.constraint(
3009
- expr`${sum(exclusiveCoupons, (c) => useCoupon[c.id])} <= 1`,
3010
- "at_most_one_exclusive"
3011
- );
3680
+ const exclusiveVars = coupons.filter((c) => !c.stackable).map((c) => useCoupon[c.id]);
3681
+ m.constraint(expr`sum(${exclusiveVars}) <= 1`, "at_most_one_exclusive");
3012
3682
 
3013
3683
  // 3. Max 2 coupons per order
3014
- m.constraint(
3015
- expr`${sum(coupons, (c) => useCoupon[c.id])} <= 2`,
3016
- "max_two_coupons"
3017
- );
3684
+ m.constraint(expr`sum(${useCoupon}) <= 2`, "max_two_coupons");
3018
3685
 
3019
3686
  // Objective: Minimize final net payable amount (merchSpend - totalDiscounts)
3020
- const totalDiscount = sum(coupons, (c) => useCoupon[c.id].mul(c.discount));
3021
- m.objective(minimize(merchSpend.sub(totalDiscount)));
3687
+ m.objective(minimize(expr`${merchSpend} - dot(${discounts}, ${useCoupon})`));
3022
3688
 
3023
3689
  const status = await m.solve();
3024
3690
  console.log(`Status: ${status}, Optimal Net Payable: $${m.objectiveValue}`);
3025
- console.log(`Recommended Add-on (+$10 USB-C Cable): ${(addUsbCable.value ?? 0) > 0.5 ? "YES" : "NO"}`);
3691
+ console.log(`Recommended Add-on (+$10 USB-C Cable): ${addUsbCable.value === 1 ? "YES" : "NO"}`);
3026
3692
  for (const cpn of coupons) {
3027
- const applied = (useCoupon[cpn.id].value ?? 0) > 0.5;
3693
+ const applied = useCoupon[cpn.id].value === 1;
3028
3694
  console.log(` ${applied ? "✅ [APPLIED]" : " [skipped]"} ${cpn.id.padEnd(11)} (-$${cpn.discount})`);
3029
3695
  }
3030
3696
  }
@@ -3038,7 +3704,7 @@ optimizeCheckoutCart();
3038
3704
 
3039
3705
  | Problem Class | Solver | Key APIs | Links |
3040
3706
  | :--- | :--- | :--- | :--- |
3041
- | Convex Quadratic Programming (QP) | HiGHS (default) | `m.continuous()`, `m.constraint()`, `expr`, `minimize`, `dot`, `QuadExpr` | <ul><li><a href="https://ohtaman.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> |
3042
3708
 
3043
3709
  The Markowitz mean-variance portfolio optimization problem is the foundation of modern quantitative finance: allocate capital across risky assets to minimize overall portfolio variance (risk) subject to achieving a minimum required expected return.
3044
3710
 
@@ -3058,7 +3724,7 @@ where $\boldsymbol{\Sigma}$ is the positive semi-definite asset return covarianc
3058
3724
  ## Implementation in WaSHI
3059
3725
 
3060
3726
  ```typescript
3061
- import { Model, QuadExpr, sum, dot, minimize, expr } from "@ohtaman/washi";
3727
+ import { Model, QuadExpr, dot, minimize, expr } from "@ohtaman/washi";
3062
3728
 
3063
3729
  async function solvePortfolio() {
3064
3730
  const m = new Model("PortfolioQP");
@@ -3080,11 +3746,12 @@ async function solvePortfolio() {
3080
3746
  const w = m.continuous(assets, { lb: 0, ub: 1 });
3081
3747
 
3082
3748
  // 1. Budget constraint: sum(w_i) == 1.0 (100%)
3083
- m.constraint(expr`${sum(assets, (a) => w[a])} == 1.0`, "budget_limit");
3749
+ m.constraint(expr`sum(${w}) == 1.0`, "budget_limit");
3084
3750
 
3085
3751
  // 2. Expected return constraint: sum(r_i * w_i) >= 8.5%
3086
3752
  const retMap = Object.fromEntries(assets.map((a, i) => [a, expectedReturns[i]]));
3087
- m.constraint(expr`${dot(retMap, w)} >= ${targetReturn}`, "target_return");
3753
+ const portfolioReturn = dot(retMap, w);
3754
+ m.constraint(expr`${portfolioReturn} >= ${targetReturn}`, "target_return");
3088
3755
 
3089
3756
  // 3. Quadratic Objective: minimize risk w^T * Sigma * w
3090
3757
  let risk = new QuadExpr();
@@ -3103,6 +3770,7 @@ async function solvePortfolio() {
3103
3770
  console.log(`Status: ${status}`);
3104
3771
  console.log(`Variance: ${m.objectiveValue?.toFixed(6)}`);
3105
3772
  console.log(`Volatility (Std Dev): ${((Math.sqrt(m.objectiveValue || 0)) * 100).toFixed(2)}%`);
3773
+ console.log(`Expected Return: ${((portfolioReturn.value ?? 0) * 100).toFixed(2)}% (target >= ${(targetReturn * 100).toFixed(2)}%)`);
3106
3774
 
3107
3775
  assets.forEach((name) => {
3108
3776
  console.log(` ${name.padEnd(10)}: ${((w[name].value ?? 0) * 100).toFixed(2)}%`);
@@ -3112,13 +3780,18 @@ async function solvePortfolio() {
3112
3780
  solvePortfolio();
3113
3781
  ```
3114
3782
 
3783
+ ## Practical Notes
3784
+
3785
+ - **Tracing the Efficient Frontier (`targetReturn` & `portfolioReturn.value`)**: Because the achieved return (`8.50%`) matches `targetReturn` exactly, the return constraint is tightly binding on the Markowitz efficient frontier. Varying `targetReturn` across `[0.05, 0.12]` traces out the full risk-return frontier.
3786
+ - **Risk-Aversion Scalarization vs. Target Return Constraint**: If `targetReturn` is set above the highest single-asset return ($12\%$ for `Tech` under long-only weights), the hard constraint $\mathbf{r}^T \mathbf{w} \ge R_{\text{target}}$ becomes `"Infeasible"`. An alternative formulation that is always feasible for any risk appetite $\lambda > 0$ is to minimize the risk-adjusted objective $\min (\lambda \, \mathbf{w}^T \boldsymbol{\Sigma} \mathbf{w} - \mathbf{r}^T \mathbf{w})$.
3787
+
3115
3788
  ---
3116
3789
 
3117
3790
  # SMT Security Policies
3118
3791
 
3119
3792
  | Problem Class | Solver | Key APIs | Links |
3120
3793
  | :--- | :--- | :--- | :--- |
3121
- | Propositional Logic & SMT | Z3 (`@ohtaman/washi/solvers/z3`) | `m.bool()`, `m.integer()`, `m.constraint()`, `expr`, `m.check()` | <ul><li><a href="https://ohtaman.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> |
3122
3795
 
3123
3796
  Satisfiability Modulo Theories (SMT) combines propositional logic (Boolean SAT) with first-order theories (linear integer arithmetic, real inequalities).
3124
3797
 
@@ -3135,7 +3808,7 @@ We model a security rule system with sensor readings and logic rules:
3135
3808
  ## Implementation in WaSHI
3136
3809
 
3137
3810
  ```typescript
3138
- import { Model, not, expr } from "@ohtaman/washi";
3811
+ import { Model, implies, not, expr } from "@ohtaman/washi";
3139
3812
  import "@ohtaman/washi/solvers/z3";
3140
3813
 
3141
3814
  async function verifySecurityRules() {
@@ -3153,7 +3826,7 @@ async function verifySecurityRules() {
3153
3826
  const luxLevel = m.integer("luxLevel", { lb: 0, ub: 1000 });
3154
3827
 
3155
3828
  // Rule 1: luxLevel <= 50 implies isNight
3156
- m.constraint(luxLevel.le(50).implies(isNight), "dark_implies_night");
3829
+ m.constraint(implies(expr`${luxLevel} <= 50`, isNight), "dark_implies_night");
3157
3830
 
3158
3831
  // Rule 2: (motionDetected AND isNight AND alarmArmed) => sirenActive
3159
3832
  m.constraint(
@@ -3180,20 +3853,32 @@ async function verifySecurityRules() {
3180
3853
  console.log(`Lights On : ${lightsOn.value}`);
3181
3854
  console.log(`Door Locked : ${frontDoorUnlocked.value ? "No" : "Yes"}`);
3182
3855
  console.log(`Is Night : ${isNight.value}`);
3856
+
3857
+ // 4. Formal Safety Verification (Counterexample Search):
3858
+ // Prove that the siren CANNOT remain off under these sensor readings
3859
+ m.constraint(not(sirenActive), "counterexample_siren_off");
3860
+ const proof = await m.check({ solver: "z3" });
3861
+ console.log(`Can siren stay off? ${proof.status} (Proven safe when Unsat)`);
3862
+ console.log(`Active proof core : ${(proof.conflicts ?? []).join(", ")}`);
3183
3863
  }
3184
3864
 
3185
3865
  verifySecurityRules();
3186
3866
  ```
3187
3867
 
3868
+ ## Practical Notes
3869
+
3870
+ - **Proof by Refutation (Counterexample Search)**: A single `Sat` check shows that *a* valid assignment exists. To formally prove that a safety property $P$ (such as `sirenActive`) **must** hold in *every* valid state, assert its negation `not(P)` and verify that `m.check()` returns `"Unsat"`. If it returns `"Sat"`, the solver's variable values give you a concrete counterexample (security loophole).
3871
+ - **One-Way Implication (`implies`) vs. Equivalence**: In classical logic, `` implies(expr`${luxLevel} <= 50`, isNight) `` only constrains `isNight` when `luxLevel <= 50`. During daytime (`luxLevel > 50`), `isNight` is unconstrained ("open-world" assumption) and could be assigned either `true` or `false` unless you also add the converse rule `` implies(expr`${luxLevel} >= 51`, not(isNight)) ``.
3872
+
3188
3873
  ---
3189
3874
 
3190
3875
  # Conflict Diagnosis (Unsat Core)
3191
3876
 
3192
3877
  | Problem Class | Solver | Key APIs | Links |
3193
3878
  | :--- | :--- | :--- | :--- |
3194
- | Infeasibility Diagnosis / Unsat Core | Z3 (`@ohtaman/washi/solvers/z3`) | `m.integer()`, `m.continuous()`, `m.constraint()`, `expr`, `m.check()`, `m.conflicts` | <ul><li><a href="https://ohtaman.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> |
3195
3880
 
3196
- 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.
3197
3882
 
3198
3883
  This example shows how to isolate the conflicting constraint subset using Z3 Unsat Core extraction.
3199
3884
 
@@ -3213,7 +3898,7 @@ $$8 \text{ engineers} \times \$8,000 = \$64,000 > \$50,000 \text{ budget!}$$
3213
3898
  ## Implementation in WaSHI
3214
3899
 
3215
3900
  ```typescript
3216
- import { Model, expr } from "@ohtaman/washi";
3901
+ import { Model, implies, expr } from "@ohtaman/washi";
3217
3902
  import "@ohtaman/washi/solvers/z3";
3218
3903
 
3219
3904
  async function diagnoseConflicts() {
@@ -3230,7 +3915,7 @@ async function diagnoseConflicts() {
3230
3915
  m.constraint(rushDelivery, "rush_required");
3231
3916
 
3232
3917
  // Rule 3: Rush delivery requires at least 8 engineers
3233
- m.constraint(rushDelivery.implies(engineers.ge(8)), "rush_staffing_rule");
3918
+ m.constraint(implies(rushDelivery, expr`${engineers} >= 8`), "rush_staffing_rule");
3234
3919
 
3235
3920
  // Rule 4: Board budget cap of $50,000 (Conflict!)
3236
3921
  m.constraint(expr`${budget} <= 50000`, "strict_board_budget_cap");
@@ -3242,8 +3927,381 @@ async function diagnoseConflicts() {
3242
3927
  const res = await m.check({ solver: "z3" });
3243
3928
 
3244
3929
  console.log(`Status: ${res.status}`);
3245
- console.log("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);
3246
3935
  }
3247
3936
 
3248
3937
  diagnoseConflicts();
3249
3938
  ```
3939
+
3940
+ ## Practical Notes
3941
+
3942
+ - **Z3 `unsatCore` vs. HiGHS Elastic Slack `conflicts`**:
3943
+ - **Z3 (`m.check({ solver: "z3" })`)** performs logical assumption tracking and returns all four rules that jointly form the contradiction (`budget_limit`, `rush_required`, `rush_staffing_rule`, `strict_board_budget_cap`), while excluding the innocent `minimum_one_employee` rule.
3944
+ - **HiGHS (`m.check({ solver: "highs" })`)** solves a single $L_1$ elastic slack minimization problem ($\min \sum s_i$). Because relaxing `rush_staffing_rule` (or `rush_required`) requires a slack of only `1`–`2` units whereas relaxing `strict_board_budget_cap` requires a slack of `14,000` dollars, unweighted $L_1$ minimization naturally highlights the smallest-magnitude repair (`rush_required`) needed to restore feasibility.
3945
+
3946
+ ---
3947
+
3948
+ # Capacitated Vehicle Routing Problem (CVRP, 50 Customers via Column Generation)
3949
+
3950
+ | Problem Class | Solver | Key APIs | Links |
3951
+ | :--- | :--- | :--- | :--- |
3952
+ | Combinatorial OR / Dantzig-Wolfe | HiGHS (default) | `m.solveColumnGeneration()`, `pricing`, `m.binary()`, `m.constraint()`, `expr` (`sum`) | <ul><li><a href="https://github.com/ohtaman/washi/blob/main/examples/node/13-vehicle-routing-column-generation.ts">Source Code</a></li><li><a href="/guide/decomposition">Decomposition Guide</a></li></ul> |
3953
+
3954
+ The **Capacitated Vehicle Routing Problem (CVRP)** determines the minimum number of delivery vehicles needed to visit a set of customers, subject to vehicle cargo capacity, maximum customer stops per shift, and route duration limits.
3955
+
3956
+ In a monolithic MIP formulation, enumerating all possible customer combinations and visit sequences leads to $O(2^n)$ binary variables. With **Column Generation (Dantzig-Wolfe Decomposition)**, routes are generated on demand:
3957
+ 1. **Restricted Master Problem (RMP)**: Solves a set partitioning problem that selects a subset of candidate routes so that every customer is visited once.
3958
+ 2. **Pricing Subproblem**: Uses the dual values (shadow prices) from the master LP relaxation to construct improving routes with negative reduced cost ($\bar{c} = 1 - \sum \pi_i < 0$) via cheapest insertion and 2-opt local search.
3959
+
3960
+ ## Problem Inputs
3961
+
3962
+ We consider a distribution network with a central depot at $(0, 0)$ and **50 customer locations**:
3963
+
3964
+ - **Vehicle Cargo Capacity**: At most **10 units** per vehicle.
3965
+ - **Maximum Stops**: At most **4 customers** per route.
3966
+ - **Maximum Shift Duration**: At most **200 minutes** (transit time + service time) per route.
3967
+ - **Transit & Service Times**: Euclidean distances between nodes (1 unit = 1 minute) plus **10 minutes** of unloading service time per customer.
3968
+
3969
+ ## Implementation in WaSHI
3970
+
3971
+ ```typescript
3972
+ import { Model, expr, minimize } from "@ohtaman/washi";
3973
+
3974
+ // --- Extracted Routing Helpers ---
3975
+
3976
+ // 2-opt local search to eliminate crossing paths in a tour
3977
+ function optimize2Opt(tour, dist) {
3978
+ let best = [...tour];
3979
+ let improved = true;
3980
+
3981
+ while (improved) {
3982
+ improved = false;
3983
+ for (let i = 1; i < best.length - 2; i++) {
3984
+ for (let j = i + 1; j < best.length - 1; j++) {
3985
+ const dCurrent = dist(best[i - 1], best[i]) + dist(best[j], best[j + 1]);
3986
+ const dSwap = dist(best[i - 1], best[j]) + dist(best[i], best[j + 1]);
3987
+ if (dSwap < dCurrent) {
3988
+ best = [...best.slice(0, i), ...best.slice(i, j + 1).reverse(), ...best.slice(j + 1)];
3989
+ improved = true;
3990
+ }
3991
+ }
3992
+ }
3993
+ }
3994
+ return best;
3995
+ }
3996
+
3997
+ // Inserts a customer into the tour at the position that minimizes detour distance
3998
+ function insertCheapest(tour, id, dist) {
3999
+ let bestPos = 1;
4000
+ let minCost = Infinity;
4001
+
4002
+ for (let p = 1; p < tour.length; p++) {
4003
+ const cost = dist(tour[p - 1], id) + dist(id, tour[p]) - dist(tour[p - 1], tour[p]);
4004
+ if (cost < minCost) {
4005
+ minCost = cost;
4006
+ bestPos = p;
4007
+ }
4008
+ }
4009
+ return [...tour.slice(0, bestPos), id, ...tour.slice(bestPos)];
4010
+ }
4011
+
4012
+ // Calculates transit time and total duration (transit + service) for a tour
4013
+ function calcRouteTime(tour, dist, serviceTimes) {
4014
+ let travelTime = 0;
4015
+ for (let i = 0; i < tour.length - 1; i++) {
4016
+ travelTime += dist(tour[i], tour[i + 1]);
4017
+ }
4018
+ const serviceTime = tour.slice(1, -1).reduce((acc, id) => acc + (serviceTimes[id] ?? 0), 0);
4019
+ return { travelTime, totalTime: travelTime + serviceTime };
4020
+ }
4021
+
4022
+ // Pricing subproblem: Finds an improving route with negative reduced cost (1 - dualSum < 0)
4023
+ function findImprovingRoute(candidates, config) {
4024
+ let bestRoute = null;
4025
+ let minReducedCost = -1e-4;
4026
+
4027
+ for (const seed of candidates.slice(0, 15)) {
4028
+ let tour = ["Depot", seed.id, "Depot"];
4029
+ let load = seed.demand;
4030
+ let dualSum = seed.dual;
4031
+
4032
+ for (const next of candidates) {
4033
+ if (tour.includes(next.id)) continue;
4034
+ if (load + next.demand > config.vehicleCapacity) continue;
4035
+ if (tour.length - 2 >= config.maxStops) continue;
4036
+
4037
+ const candidateTour = optimize2Opt(insertCheapest(tour, next.id, config.dist), config.dist);
4038
+ const { totalTime } = calcRouteTime(candidateTour, config.dist, config.serviceTimes);
4039
+
4040
+ if (totalTime <= config.maxRouteTime) {
4041
+ tour = candidateTour;
4042
+ load += next.demand;
4043
+ dualSum += next.dual;
4044
+ }
4045
+ }
4046
+
4047
+ const reducedCost = 1 - dualSum;
4048
+ if (reducedCost < minReducedCost) {
4049
+ minReducedCost = reducedCost;
4050
+ bestRoute = tour;
4051
+ }
4052
+ }
4053
+ return bestRoute;
4054
+ }
4055
+
4056
+ // --- Main CVRP via Column Generation ---
4057
+
4058
+ async function runCVRP() {
4059
+ const vehicleCapacity = 10;
4060
+ const maxStops = 4;
4061
+ const maxRouteTime = 200; // minutes
4062
+
4063
+ // Central depot and 50 customer locations
4064
+ const depot = { id: "Depot", x: 0, y: 0 };
4065
+ const customers = Array.from({ length: 50 }, (_, i) => {
4066
+ const angle = i * 2.4;
4067
+ const radius = 15 + (i % 5) * 8;
4068
+ return {
4069
+ id: `C${i + 1}`,
4070
+ x: Math.round(Math.cos(angle) * radius),
4071
+ y: Math.round(Math.sin(angle) * radius),
4072
+ demand: 1 + (i % 4),
4073
+ serviceTime: 10,
4074
+ };
4075
+ });
4076
+
4077
+ const nodes = Object.fromEntries([depot, ...customers].map((n) => [n.id, n]));
4078
+ const dist = (a, b) =>
4079
+ Math.round(Math.hypot(nodes[a].x - nodes[b].x, nodes[a].y - nodes[b].y));
4080
+ const serviceTimes = Object.fromEntries(customers.map((c) => [c.id, c.serviceTime]));
4081
+
4082
+ const routeConfig = { dist, serviceTimes, vehicleCapacity, maxStops, maxRouteTime };
4083
+
4084
+ // 1. Build Restricted Master Problem (RMP)
4085
+ const master = new Model("CVRP_FleetMaster");
4086
+
4087
+ // Initial naive routes: 1 dedicated vehicle per customer
4088
+ const initialRoutes = customers.map((c) => master.binary(`route_init_${c.id}`));
4089
+ master.objective(minimize(expr`sum(${initialRoutes})`));
4090
+
4091
+ // Each customer must be visited by exactly 1 vehicle
4092
+ const visitConstrs = customers.map((c, i) =>
4093
+ master.constraint(expr`${initialRoutes[i]} == 1`, `visit_${c.id}`)
4094
+ );
4095
+
4096
+ console.log(`Initial state: 50 naive single-customer routes (50 vehicles).`);
4097
+ console.log("Solving via Column Generation with dual-directed heuristic pricing...");
4098
+
4099
+ // 2. Solve via Column Generation
4100
+ const result = await master.solveColumnGeneration({
4101
+ finalizeAsMIP: true,
4102
+ pricing: async () => {
4103
+ // 1. Sort customers by shadow price (dual profit) descending
4104
+ const candidates = customers
4105
+ .map((c, i) => ({ id: c.id, demand: c.demand, dual: visitConstrs[i].dual ?? 0 }))
4106
+ .filter((c) => c.dual > 0)
4107
+ .sort((a, b) => b.dual - a.dual);
4108
+
4109
+ // 2. Solve pricing subproblem: find route with negative reduced cost (dualSum > 1)
4110
+ const bestRoute = findImprovingRoute(candidates, routeConfig);
4111
+ if (!bestRoute) return null; // No improving column found
4112
+
4113
+ // 3. Propose new column to Restricted Master Problem
4114
+ const { travelTime, totalTime } = calcRouteTime(bestRoute, dist, serviceTimes);
4115
+ const coefficients = {};
4116
+ for (const id of bestRoute.slice(1, -1)) {
4117
+ coefficients[`visit_${id}`] = 1;
4118
+ }
4119
+
4120
+ return {
4121
+ cost: 1,
4122
+ type: "Binary",
4123
+ coefficients,
4124
+ meta: { tour: bestRoute, travelTime, totalTime },
4125
+ };
4126
+ },
4127
+ });
4128
+
4129
+ console.log(`\nStatus: ${result.status}`);
4130
+ console.log(`Fleet Size: ${Math.round(result.objectiveValue ?? 0)} vehicles (heuristic column generation)`);
4131
+ console.log(`Total Columns Generated: ${result.columnsAdded}`);
4132
+
4133
+ console.log("\nSample routes selected in fleet:");
4134
+ const activeRoutes = result.generatedColumns.filter((c) => c.variable.value === 1);
4135
+ for (const r of activeRoutes.slice(0, 5)) {
4136
+ const meta = r.proposal.meta;
4137
+ console.log(` ${meta?.tour.join(" -> ")} (Transit: ${meta?.travelTime}m, Total: ${meta?.totalTime}m)`);
4138
+ }
4139
+ }
4140
+
4141
+ runCVRP();
4142
+ ```
4143
+
4144
+ ## Notes
4145
+
4146
+ - **Master Problem and Pricing Separation**: The master problem solves the LP relaxation and final set-partitioning MIP, while the `pricing` callback searches for negative-reduced-cost routes using cheapest insertion and 2-opt local search.
4147
+ - **Dual Value Access**: Dual values from the master LP relaxation are read from `visitConstrs[i].dual` to prioritize customers with higher shadow prices during route construction.
4148
+ - **Route Metadata**: Each proposed column attaches `{ tour, travelTime, totalTime }` under `meta`, allowing the ordered stop sequence to be inspected from `result.generatedColumns` after the solve completes.
4149
+ - **Set Partitioning (`== 1`) vs. Set Covering (`>= 1`)**: This example uses `== 1` so that the final MIP never covers a customer twice. In large-scale column generation, formulating coverage as `>= 1` restricts the dual prices to $\pi_i \ge 0$, avoiding negative dual oscillations; if the final MIP selects overlapping routes, duplicate visits can be removed by post-processing whenever distances satisfy the triangle inequality.
4150
+ - **Heuristic Pricing vs. Exact ESPPRC**: Because `findImprovingRoute` uses a fast greedy heuristic (cheapest insertion + 2-opt) instead of an exact Elementary Shortest Path Problem with Resource Constraints (ESPPRC) solver, returning `null` means no further improving columns were found by the heuristic rather than proving global LP optimality.
4151
+ - **Price-and-Branch (`finalizeAsMIP: true`)**: Solving the Restricted Master Problem as a MIP over the columns generated at the root node is known as the *Restricted Master Heuristic* (Price-and-Branch). It produces near-optimal integer fleets quickly without requiring full Branch-and-Price tree search.
4152
+
4153
+ ---
4154
+
4155
+ # Traveling Salesperson Problem (TSP via Benders Decomposition)
4156
+
4157
+ | Problem Class | Solver | Key APIs | Links |
4158
+ | :--- | :--- | :--- | :--- |
4159
+ | Combinatorial OR / Benders | HiGHS (default) | `m.solveBenders()`, `subproblem`, `m.binary()`, `m.constraint()`, `expr` | <ul><li><a href="https://github.com/ohtaman/washi/blob/main/examples/node/14-traveling-salesperson-benders.ts">Source Code</a></li><li><a href="/guide/decomposition">Decomposition Guide</a></li></ul> |
4160
+
4161
+ The **Traveling Salesperson Problem (TSP)** seeks the shortest closed tour that visits every city in a given set once and returns to the starting city.
4162
+
4163
+ In the **Dantzig-Fulkerson-Johnson (DFJ)** formulation, preventing disconnected cycles (**subtours**) requires subtour elimination constraints for every non-empty proper subset of cities $S \subset V$:
4164
+
4165
+ $$\sum_{i \in S, j \notin S} x_{ij} \ge 1 \quad \forall S \subset V, \; 2 \le |S| \le |V| - 1$$
4166
+
4167
+ Because there are $2^n - 2$ subsets, enumerating all subtour constraints upfront is impractical as $n$ grows. With **Benders Decomposition (Lazy Constraint Separation)**, constraints are added only when violated:
4168
+ 1. **Master Problem**: Solves the degree-assignment relaxation (each city has 1 incoming and 1 outgoing edge).
4169
+ 2. **Separation Subproblem**: Inspects the candidate solution graph. If disconnected cycles exist, it adds a **Benders Feasibility Cut** eliminating the smallest cycle and resolves until a single connected tour is obtained.
4170
+
4171
+ ## Problem Inputs
4172
+
4173
+ We consider **5 cities** on a 2D Euclidean coordinate grid:
4174
+
4175
+ | City | Coordinates $(x, y)$ |
4176
+ | :--- | :---: |
4177
+ | **City A (0)** | $(0, 0)$ |
4178
+ | **City B (1)** | $(1, 0)$ |
4179
+ | **City C (2)** | $(1, 2)$ |
4180
+ | **City D (3)** | $(0, 2)$ |
4181
+ | **City E (4)** | $(0.5, 3)$ |
4182
+
4183
+ ## Implementation in WaSHI
4184
+
4185
+ ```typescript
4186
+ import { Model, expr, minimize } from "@ohtaman/washi";
4187
+
4188
+ async function runTSP() {
4189
+ const cities = [
4190
+ { name: "A", x: 0, y: 0 },
4191
+ { name: "B", x: 1, y: 0 },
4192
+ { name: "C", x: 1, y: 2 },
4193
+ { name: "D", x: 0, y: 2 },
4194
+ { name: "E", x: 0.5, y: 3 },
4195
+ ];
4196
+ const n = cities.length;
4197
+
4198
+ function dist(i, j) {
4199
+ const dx = cities[i].x - cities[j].x;
4200
+ const dy = cities[i].y - cities[j].y;
4201
+ return Math.hypot(dx, dy);
4202
+ }
4203
+
4204
+ // 1. Master Problem: Degree assignment relaxation
4205
+ const master = new Model("TSP_BendersMaster");
4206
+ const x = {};
4207
+ const distMatrix = {};
4208
+
4209
+ for (let i = 0; i < n; i++) {
4210
+ x[i] = {};
4211
+ distMatrix[i] = {};
4212
+ for (let j = 0; j < n; j++) {
4213
+ if (i !== j) {
4214
+ x[i][j] = master.binary(`x_${i}_${j}`);
4215
+ distMatrix[i][j] = dist(i, j);
4216
+ }
4217
+ }
4218
+ }
4219
+
4220
+ // Objective: Minimize total tour distance
4221
+ master.objective(minimize(expr`dot(${distMatrix}, ${x})`));
4222
+
4223
+ // Each city must have 1 outgoing and 1 incoming edge
4224
+ for (let i = 0; i < n; i++) {
4225
+ master.constraint(expr`sum(${x[i]}) == 1`, `out_${i}`);
4226
+ master.constraint(expr`sum(${n}, ${(j) => x[j][i]}) == 1`, `in_${i}`);
4227
+ }
4228
+
4229
+ console.log("Solving TSP with Benders lazy subtour elimination...");
4230
+
4231
+ // 2. Solve via Benders Decomposition
4232
+ let cutCounter = 0;
4233
+ const result = await master.solveBenders({
4234
+ subproblem: () => {
4235
+ // Find active directed edges in the master solution
4236
+ const succ = {};
4237
+ for (let i = 0; i < n; i++) {
4238
+ for (let j = 0; j < n; j++) {
4239
+ if (i !== j && x[i][j].value === 1) {
4240
+ succ[i] = j;
4241
+ }
4242
+ }
4243
+ }
4244
+
4245
+ // Trace connected cycles
4246
+ const visited = new Set();
4247
+ const cycles = [];
4248
+ for (let i = 0; i < n; i++) {
4249
+ if (!visited.has(i)) {
4250
+ const cycle = [];
4251
+ let curr = i;
4252
+ while (!visited.has(curr)) {
4253
+ visited.add(curr);
4254
+ cycle.push(curr);
4255
+ curr = succ[curr];
4256
+ }
4257
+ cycles.push(cycle);
4258
+ }
4259
+ }
4260
+
4261
+ // If more than 1 cycle, subtours exist: inject Benders feasibility cuts for all cycles
4262
+ if (cycles.length > 1) {
4263
+ return cycles.map((cycle) => {
4264
+ const S = new Set(cycle);
4265
+ cutCounter++;
4266
+ console.log(`Subtour detected: [${cycle.map(k => cities[k].name).join(" -> ")}]. Adding Benders cut #${cutCounter}.`);
4267
+
4268
+ const outCities = Array.from({ length: n }, (_, j) => j).filter((j) => !S.has(j));
4269
+ const cutEdges = cycle.flatMap((i) => outCities.map((j) => x[i][j]));
4270
+
4271
+ return {
4272
+ kind: "feasibility",
4273
+ constraint: expr`sum(${cutEdges}) >= 1`,
4274
+ name: `subtour_cut_${cutCounter}`,
4275
+ };
4276
+ });
4277
+ }
4278
+
4279
+ return null; // Single connected tour found
4280
+ },
4281
+ });
4282
+
4283
+ console.log(`\nStatus: ${result.status}`);
4284
+ console.log(`Optimal Tour Distance: ${result.objectiveValue?.toFixed(3)}`);
4285
+ console.log(`Total Benders Cuts Added: ${result.cutsAdded}`);
4286
+ console.log(`Iterations: ${result.iterations}`);
4287
+
4288
+ console.log("\nOptimal tour edges:");
4289
+ for (let i = 0; i < n; i++) {
4290
+ for (let j = 0; j < n; j++) {
4291
+ if (i !== j && result.values[`x_${i}_${j}`] === 1) {
4292
+ console.log(` ${cities[i].name} -> ${cities[j].name} (distance: ${dist(i, j).toFixed(3)})`);
4293
+ }
4294
+ }
4295
+ }
4296
+ }
4297
+
4298
+ runTSP();
4299
+ ```
4300
+
4301
+ ## Notes
4302
+
4303
+ - **Lazy Subtour Elimination**: The master problem starts with only the $2n$ degree constraints, and subtour elimination cuts are added on demand when disconnected cycles appear in the master solution.
4304
+ - **Multi-Cut Separation**: Returning an array of cuts (`cycles.map(...)`) from `subproblem` eliminates all disconnected cycles discovered in the current candidate solution simultaneously, reducing the number of master MIP re-solves.
4305
+ - **Two Equivalent DFJ Cut Forms**: Because every city has out-degree $\sum_{j \ne i} x_{ij} = 1$, summing over $i \in S$ gives $\sum_{i \in S, j \in S} x_{ij} + \sum_{i \in S, j \notin S} x_{ij} = |S|$. Thus the cut-set inequality $\sum_{i \in S, j \notin S} x_{ij} \ge 1$ used above and the internal-edge inequality $\sum_{i \in S, j \in S} x_{ij} \le |S| - 1$ used in [`14-traveling-salesperson-benders.ts`](https://github.com/ohtaman/washi/blob/main/examples/node/14-traveling-salesperson-benders.ts) are algebraically identical.
4306
+ - **Upfront 2-City Subtour Elimination**: In larger TSP instances, mutual 2-cycles ($i \leftrightarrow j$) are the most frequent initial subtours. Adding the $\binom{n}{2}$ pairwise constraints $x_{ij} + x_{ji} \le 1$ directly to the initial master problem often cuts the number of Benders iterations substantially.
4307
+ - **Termination Condition**: Returning `null` from `subproblem` indicates that no violated subtour constraints remain.