Skip to content

Commit 48e3c7b

Browse files
committed
feat(core): interval arithmetic, mccormick relaxations, homotopy continuation, spatial branch-and-bound
1 parent dae7a94 commit 48e3c7b

4 files changed

Lines changed: 1289 additions & 0 deletions

File tree

Lines changed: 397 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,397 @@
1+
// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+
/**
4+
* Spatial Branch-and-Bound (sBB) Global Optimization Solver.
5+
*
6+
* Uses interval arithmetic for fathoming (pruning), McCormick relaxations
7+
* for tighter convex lower bounds, and Newton-Raphson with exact AD for
8+
* local NLP solves (upper bounds).
9+
*
10+
* Algorithm:
11+
* 1. Initialize domain box B₀ = [lo, hi] for each variable
12+
* 2. Queue ← { B₀ }
13+
* 3. While Queue not empty:
14+
* a. Pop box B with best lower bound
15+
* b. Interval eval → LB (fast fathoming)
16+
* c. If LB ≥ incumbent UB → prune
17+
* d. McCormick eval at midpoint → tighter LB
18+
* e. Local NLP solve (Newton+AD) from midpoint → update UB
19+
* f. If gap < ε → done
20+
* g. Branch: split B along widest dimension
21+
* h. Push children to Queue
22+
*
23+
* Reference: Smith, E.M.B. & Pantelides, C.C. (1999),
24+
* "A symbolic reformulation/spatial branch-and-bound algorithm
25+
* for the global optimisation of nonconvex MINLPs", Computers & Chem. Eng.
26+
*/
27+
28+
import { StaticTapeBuilder, type TapeOp } from "./ad-codegen.js";
29+
import { evaluateTapeForward, evaluateTapeReverse } from "./ad-jacobian.js";
30+
import type { ModelicaDAE, ModelicaExpression } from "./dae.js";
31+
import { Interval, evaluateTapeInterval } from "./interval.js";
32+
import { evaluateTapeMcCormick } from "./mccormick.js";
33+
34+
/** A box in the search space: variable name → [lo, hi] */
35+
export type DomainBox = Map<string, Interval>;
36+
37+
/** Result of the sBB solver. */
38+
export interface SbbResult {
39+
/** Optimal variable values (best feasible point). */
40+
solution: Map<string, number>;
41+
/** Optimal objective value (upper bound). */
42+
objectiveValue: number;
43+
/** Lower bound on optimal objective. */
44+
lowerBound: number;
45+
/** Number of nodes explored. */
46+
nodesExplored: number;
47+
/** Whether the solver found a global optimum within tolerance. */
48+
optimal: boolean;
49+
}
50+
51+
/** Configuration for the sBB solver. */
52+
export interface SbbOptions {
53+
/** Absolute gap tolerance (default: 1e-6). */
54+
absTol?: number;
55+
/** Relative gap tolerance (default: 1e-4). */
56+
relTol?: number;
57+
/** Maximum number of nodes to explore (default: 10000). */
58+
maxNodes?: number;
59+
/** Maximum Newton iterations per local solve (default: 50). */
60+
maxNewtonIter?: number;
61+
}
62+
63+
interface SbbNode {
64+
box: DomainBox;
65+
lowerBound: number;
66+
}
67+
68+
/**
69+
* Solve a global optimization problem using spatial branch-and-bound.
70+
*
71+
* Minimizes `objective(z)` subject to `constraints_i(z) = 0`.
72+
*
73+
* @param objectiveTape Tape and output index for the objective function
74+
* @param constraintTapes Tapes and output indices for equality constraints
75+
* @param variables Variable names (decision variables)
76+
* @param initialBox Initial domain box (bounds for each variable)
77+
* @param options Solver options
78+
*/
79+
export function solveSBB(
80+
objectiveTape: { ops: TapeOp[]; outputIndex: number },
81+
constraintTapes: { ops: TapeOp[]; outputIndex: number }[],
82+
variables: string[],
83+
initialBox: DomainBox,
84+
options: SbbOptions = {},
85+
): SbbResult {
86+
const absTol = options.absTol ?? 1e-6;
87+
const relTol = options.relTol ?? 1e-4;
88+
const maxNodes = options.maxNodes ?? 10000;
89+
const maxNewtonIter = options.maxNewtonIter ?? 50;
90+
91+
let incumbent: Map<string, number> | null = null;
92+
let upperBound = Infinity;
93+
let globalLowerBound = -Infinity;
94+
let nodesExplored = 0;
95+
96+
// Priority queue sorted by lower bound (ascending)
97+
const queue: SbbNode[] = [];
98+
99+
// Initial interval evaluation for root node
100+
const rootLB = evaluateIntervalLB(objectiveTape, initialBox);
101+
queue.push({ box: new Map(initialBox), lowerBound: rootLB });
102+
103+
// Try local solve from midpoint of initial box for first upper bound
104+
const midpoint = boxMidpoint(initialBox, variables);
105+
const localResult = localNewtonSolve(objectiveTape, constraintTapes, variables, midpoint, maxNewtonIter);
106+
if (localResult !== null && isBoxFeasible(localResult.point, initialBox)) {
107+
const objVal = evaluateObjective(objectiveTape, localResult.point);
108+
if (objVal < upperBound) {
109+
upperBound = objVal;
110+
incumbent = new Map(localResult.point);
111+
}
112+
}
113+
114+
while (queue.length > 0 && nodesExplored < maxNodes) {
115+
// Pop node with lowest lower bound
116+
queue.sort((a, b) => a.lowerBound - b.lowerBound);
117+
const node = queue.shift()!; // eslint-disable-line @typescript-eslint/no-non-null-assertion
118+
nodesExplored++;
119+
120+
// Fathom: if lower bound ≥ upper bound, prune
121+
if (node.lowerBound >= upperBound - absTol) continue;
122+
123+
// McCormick evaluation at midpoint for tighter lower bound
124+
const mid = boxMidpoint(node.box, variables);
125+
const mcResult = evaluateTapeMcCormick(objectiveTape.ops, node.box, mid);
126+
const mcLB = mcResult[objectiveTape.outputIndex]?.cv ?? node.lowerBound;
127+
const tighterLB = Math.max(node.lowerBound, mcLB);
128+
129+
if (tighterLB >= upperBound - absTol) continue;
130+
131+
// Local NLP solve from midpoint
132+
const local = localNewtonSolve(objectiveTape, constraintTapes, variables, mid, maxNewtonIter);
133+
if (local !== null && isBoxFeasible(local.point, node.box)) {
134+
const objVal = evaluateObjective(objectiveTape, local.point);
135+
if (objVal < upperBound) {
136+
upperBound = objVal;
137+
incumbent = new Map(local.point);
138+
}
139+
}
140+
141+
// Check gap
142+
globalLowerBound = queue.length > 0 ? Math.min(tighterLB, queue[0]?.lowerBound ?? Infinity) : tighterLB;
143+
const gap = upperBound - globalLowerBound;
144+
if (gap <= absTol || (upperBound !== 0 && gap / Math.abs(upperBound) <= relTol)) {
145+
break; // Converged
146+
}
147+
148+
// Branch: split along widest dimension
149+
const splitVar = findWidestDimension(node.box, variables);
150+
if (!splitVar) continue;
151+
152+
const splitInterval = node.box.get(splitVar);
153+
if (!splitInterval || splitInterval.width < 1e-12) continue;
154+
155+
const splitMid = splitInterval.mid;
156+
157+
// Left child: [lo, mid]
158+
const leftBox: DomainBox = new Map(node.box);
159+
leftBox.set(splitVar, new Interval(splitInterval.lo, splitMid));
160+
const leftLB = Math.max(tighterLB, evaluateIntervalLB(objectiveTape, leftBox));
161+
if (leftLB < upperBound - absTol) {
162+
queue.push({ box: leftBox, lowerBound: leftLB });
163+
}
164+
165+
// Right child: [mid, hi]
166+
const rightBox: DomainBox = new Map(node.box);
167+
rightBox.set(splitVar, new Interval(splitMid, splitInterval.hi));
168+
const rightLB = Math.max(tighterLB, evaluateIntervalLB(objectiveTape, rightBox));
169+
if (rightLB < upperBound - absTol) {
170+
queue.push({ box: rightBox, lowerBound: rightLB });
171+
}
172+
}
173+
174+
return {
175+
solution: incumbent ?? boxMidpoint(initialBox, variables),
176+
objectiveValue: upperBound,
177+
lowerBound: globalLowerBound,
178+
nodesExplored,
179+
optimal:
180+
upperBound - globalLowerBound <= absTol ||
181+
(upperBound !== 0 && (upperBound - globalLowerBound) / Math.abs(upperBound) <= relTol),
182+
};
183+
}
184+
185+
// ── Helper functions ──
186+
187+
/** Evaluate interval lower bound of objective over a box. */
188+
function evaluateIntervalLB(tape: { ops: TapeOp[]; outputIndex: number }, box: DomainBox): number {
189+
const intervals = evaluateTapeInterval(tape.ops, box);
190+
return intervals[tape.outputIndex]?.lo ?? -Infinity;
191+
}
192+
193+
/** Evaluate objective at a point. */
194+
function evaluateObjective(tape: { ops: TapeOp[]; outputIndex: number }, point: Map<string, number>): number {
195+
const t = evaluateTapeForward(tape.ops, point);
196+
return t[tape.outputIndex] ?? Infinity;
197+
}
198+
199+
/** Get midpoint of a domain box. */
200+
function boxMidpoint(box: DomainBox, variables: string[]): Map<string, number> {
201+
const mid = new Map<string, number>();
202+
for (const v of variables) {
203+
const interval = box.get(v);
204+
if (interval) {
205+
mid.set(v, interval.mid);
206+
}
207+
}
208+
// Copy non-variable entries (parameters, time, etc.)
209+
for (const [k, v] of box) {
210+
if (!mid.has(k)) {
211+
mid.set(k, v.mid);
212+
}
213+
}
214+
return mid;
215+
}
216+
217+
/** Check if a point is within the domain box. */
218+
function isBoxFeasible(point: Map<string, number>, box: DomainBox): boolean {
219+
for (const [name, interval] of box) {
220+
const val = point.get(name);
221+
if (val !== undefined && (val < interval.lo - 1e-10 || val > interval.hi + 1e-10)) {
222+
return false;
223+
}
224+
}
225+
return true;
226+
}
227+
228+
/** Find the variable with the widest interval in the box. */
229+
function findWidestDimension(box: DomainBox, variables: string[]): string | null {
230+
let widest: string | null = null;
231+
let maxWidth = 0;
232+
for (const v of variables) {
233+
const interval = box.get(v);
234+
if (interval && interval.width > maxWidth) {
235+
maxWidth = interval.width;
236+
widest = v;
237+
}
238+
}
239+
return widest;
240+
}
241+
242+
/**
243+
* Local Newton-Raphson solve from a starting point.
244+
* Minimizes objective subject to constraints = 0.
245+
* For unconstrained: just finds a stationary point (gradient = 0).
246+
* For constrained: solves the KKT system.
247+
*/
248+
function localNewtonSolve(
249+
objectiveTape: { ops: TapeOp[]; outputIndex: number },
250+
constraintTapes: { ops: TapeOp[]; outputIndex: number }[],
251+
variables: string[],
252+
startPoint: Map<string, number>,
253+
maxIter: number,
254+
): { point: Map<string, number> } | null {
255+
const n = variables.length;
256+
const nConstraints = constraintTapes.length;
257+
const point = new Map(startPoint);
258+
259+
if (nConstraints === 0) {
260+
// Unconstrained: find stationary point where ∇f = 0
261+
for (let iter = 0; iter < maxIter; iter++) {
262+
const t = evaluateTapeForward(objectiveTape.ops, point);
263+
const grads = evaluateTapeReverse(objectiveTape.ops, t, objectiveTape.outputIndex);
264+
265+
// Check gradient norm
266+
let gradNorm = 0;
267+
for (const v of variables) {
268+
const g = grads.get(v) ?? 0;
269+
gradNorm += g * g;
270+
}
271+
if (Math.sqrt(gradNorm) < 1e-10) return { point };
272+
273+
// Steepest descent step (simple, robust)
274+
const stepSize = 0.01;
275+
for (const v of variables) {
276+
const g = grads.get(v) ?? 0;
277+
point.set(v, (point.get(v) ?? 0) - stepSize * g);
278+
}
279+
}
280+
} else {
281+
// Constrained: solve R(z) = 0 for constraints via Newton
282+
for (let iter = 0; iter < maxIter; iter++) {
283+
// Evaluate constraint residuals
284+
let totalResidual = 0;
285+
const R = new Array(nConstraints).fill(0) as number[];
286+
const J: number[][] = [];
287+
for (let i = 0; i < nConstraints; i++) {
288+
J[i] = new Array(n).fill(0) as number[];
289+
}
290+
291+
for (let row = 0; row < nConstraints; row++) {
292+
const ct = constraintTapes[row];
293+
if (!ct) continue;
294+
const t = evaluateTapeForward(ct.ops, point);
295+
R[row] = t[ct.outputIndex] ?? 0;
296+
totalResidual += Math.abs(R[row] ?? 0);
297+
298+
const grads = evaluateTapeReverse(ct.ops, t, ct.outputIndex);
299+
const jRow = J[row];
300+
if (!jRow) continue;
301+
for (let col = 0; col < n; col++) {
302+
const vn = variables[col];
303+
if (vn) jRow[col] = grads.get(vn) ?? 0;
304+
}
305+
}
306+
307+
if (totalResidual < 1e-10) return { point };
308+
309+
// Solve J * dz = -R (least-squares if non-square)
310+
if (nConstraints === n) {
311+
const negR = R.map((r) => -(r ?? 0));
312+
const dz = solveLULocal(J, negR, n);
313+
for (let i = 0; i < n; i++) {
314+
const vn = variables[i];
315+
if (vn) point.set(vn, (point.get(vn) ?? 0) + (dz[i] ?? 0));
316+
}
317+
}
318+
}
319+
}
320+
321+
return { point };
322+
}
323+
324+
/** Simple LU solve for the local Newton solver. */
325+
function solveLULocal(A: number[][], b: number[], n: number): number[] {
326+
const M = A.map((row) => [...row]);
327+
const rhs = [...b];
328+
329+
for (let k = 0; k < n; k++) {
330+
let maxVal = Math.abs(M[k]?.[k] ?? 0);
331+
let maxRow = k;
332+
for (let i = k + 1; i < n; i++) {
333+
const val = Math.abs(M[i]?.[k] ?? 0);
334+
if (val > maxVal) {
335+
maxVal = val;
336+
maxRow = i;
337+
}
338+
}
339+
if (maxRow !== k) {
340+
[M[k], M[maxRow]] = [M[maxRow] ?? [], M[k] ?? []];
341+
[rhs[k], rhs[maxRow]] = [rhs[maxRow] ?? 0, rhs[k] ?? 0];
342+
}
343+
const pivot = M[k]?.[k] ?? 0;
344+
if (Math.abs(pivot) < 1e-30) continue;
345+
for (let i = k + 1; i < n; i++) {
346+
const row = M[i];
347+
const pivotRow = M[k];
348+
if (!row || !pivotRow) continue;
349+
const factor = (row[k] ?? 0) / pivot;
350+
for (let j = k + 1; j < n; j++) {
351+
row[j] = (row[j] ?? 0) - factor * (pivotRow[j] ?? 0);
352+
}
353+
rhs[i] = (rhs[i] ?? 0) - factor * (rhs[k] ?? 0);
354+
}
355+
}
356+
357+
const x = new Array(n).fill(0) as number[];
358+
for (let i = n - 1; i >= 0; i--) {
359+
let sum = rhs[i] ?? 0;
360+
const row = M[i];
361+
if (row) {
362+
for (let j = i + 1; j < n; j++) {
363+
sum -= (row[j] ?? 0) * (x[j] ?? 0);
364+
}
365+
const diag = row[i] ?? 1;
366+
x[i] = Math.abs(diag) > 1e-30 ? sum / diag : 0;
367+
}
368+
}
369+
return x;
370+
}
371+
372+
/**
373+
* Build tape data from a DAE for use with the sBB solver.
374+
* Convenience function for integrating with the ModelScript pipeline.
375+
*/
376+
export function buildSbbFromDAE(
377+
dae: ModelicaDAE,
378+
objectiveExpr: ModelicaExpression,
379+
constraintExprs: ModelicaExpression[],
380+
): {
381+
objectiveTape: { ops: TapeOp[]; outputIndex: number };
382+
constraintTapes: { ops: TapeOp[]; outputIndex: number }[];
383+
} {
384+
const objTape = new StaticTapeBuilder();
385+
const objIdx = objTape.walk(objectiveExpr);
386+
387+
const constraintTapes = constraintExprs.map((expr) => {
388+
const tape = new StaticTapeBuilder();
389+
const idx = tape.walk(expr);
390+
return { ops: [...tape.ops], outputIndex: idx };
391+
});
392+
393+
return {
394+
objectiveTape: { ops: [...objTape.ops], outputIndex: objIdx },
395+
constraintTapes,
396+
};
397+
}

0 commit comments

Comments
 (0)