The demo below runs a tiny two-stage problem. A single binary master variable y picks one of two scenarios; the subproblem then minimizes a linear cost over a continuous variable x. Each iteration the algorithm adds an optimality cut that lifts the master's lower bound.
<div class="bd-wrap">
<div class="bd-problem">
<strong>Problem:</strong> minimize <em>c·y + Q(y)</em><br>
where <em>y ∈ {0,1}</em> (master binary) and <em>Q(y) = min x</em> s.t.
<em>x ≥ a·y + b</em>, <em>x ≥ 0</em>
<br><small>Scenario A (y=0): a=0, b=8 | Scenario B (y=1): a=-3, b=5</small>
</div>
<div class="bd-chart-area">
<canvas id="bdCanvas" width="480" height="220"></canvas>
</div>
<div class="bd-legend">
<span class="dot blue"></span> Master lower bound
<span class="dot orange"></span> Subproblem upper bound
</div>
<div class="bd-log" id="bdLog"><em>Press Step to start.</em></div>
<div class="bd-status" id="bdStatus"></div>
<div class="bd-btns">
<button id="bdStep">Step</button>
<button id="bdRun">Run to convergence</button>
<button id="bdReset" class="ghost">Reset</button>
</div>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; margin: 0; color: #1a2535; }
.bd-wrap { max-width: 520px; margin: 0 auto; padding: .5rem .7rem; }
.bd-problem { background: #f0f4f8; border-left: 3px solid #3b6fd4; padding: .55rem .75rem;
border-radius: 6px; font-size: .83rem; line-height: 1.6; margin-bottom: .7rem; }
.bd-problem strong { font-size: .9rem; }
.bd-chart-area { width: 100%; overflow: hidden; margin-bottom: .4rem; }
canvas { display: block; width: 100%; height: auto; border-radius: 8px;
background: #fafbfc; border: 1px solid #dde3ea; }
.bd-legend { font-size: .78rem; color: #555; margin-bottom: .5rem; }
.dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 3px; vertical-align: middle; }
.dot.blue { background: #3b6fd4; }
.dot.orange { background: #e07b2a; }
.bd-log { font-size: .82rem; min-height: 3.4em; background: #f7f9fb; border: 1px solid #dde3ea;
border-radius: 6px; padding: .4rem .6rem; line-height: 1.55; margin-bottom: .45rem; color: #2a3d55; }
.bd-status { font-weight: 700; font-size: .95rem; min-height: 1.5em; color: #0a7a30; margin-bottom: .35rem; }
.bd-btns { display: flex; gap: .5rem; flex-wrap: wrap; }
button { font: 600 14px system-ui; padding: .42rem .85rem; border-radius: 7px; cursor: pointer;
border: 1px solid #3b6fd4; background: #3b6fd4; color: #fff; }
button.ghost { background: #fff; color: #3b6fd4; }
button:disabled { opacity: .45; cursor: default; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Benders Decomposition demo — tiny MIP:
// min 5*y + Q(y) y in {0,1}
// Q(y) = min x s.t. x >= -3*y + 8, x >= 0
//
// When y=0: subproblem min x s.t. x>=8, x>=0 => Q(0)=8 total=8
// When y=1: subproblem min x s.t. x>=5, x>=0 => Q(1)=5 total=10
// Optimal: y=0, x=8, total=8.
//
// Master starts with eta >= -Inf (modeled as -999).
// Iteration 1: master solves relaxed -> picks y=0 (lower c*y=0), eta=-999 (unconstrained).
// Master obj = 0 + (-999) = -999. Lower bound = -999.
// Subproblem for y=0: Q(0)=8. Upper bound = 0+8=8.
// Dual of subproblem LP: x >= 8, x>=0 => dual multiplier lambda=1 on x>=8.
// Optimality cut: eta >= Q(0) + lambda*(-3)*(y - 0) = 8 - 3*y (linearized).
// Cut: eta >= 8 - 3*y.
//
// Iteration 2: master with cut eta>=8-3y, min 5y+eta.
// y=0 => eta>=8 => obj=8. y=1 => eta>=5 => obj=10. Master picks y=0,eta=8. LB=8.
// Subproblem y=0: Q(0)=8. UB=8. Gap=0 -> converged.
//
// We’ll show 3 steps: init, iter1, iter2 (converged).
const STEPS = [
{
iter: 0,
desc: `Initial state. The master has no cuts yet — eta is unbounded below. Master lower bound = −∞ (shown as −999). No subproblem evaluated yet.`,
lbHistory: [],
ubHistory: [],
cuts: [],
converged: false
},
{
iter: 1,
desc: `Iteration 1 — Master (no cuts): picks y=0, eta=−999 → LB = −999.
Subproblem (y=0): Q(0) = 8 → UB = 5·0 + 8 = 8.
Optimality cut added: η ≥ 8 − 3y (derived from dual multiplier λ=1).`,
lbHistory: [-999],
ubHistory: [8],
cuts: ['η ≥ 8 − 3y'],
converged: false
},
{
iter: 2,
desc: `Iteration 2 — Master (with cut η≥8−3y): y=0→obj=8, y=1→obj=10 → picks y=0,η=8 → LB = 8.
Subproblem (y=0): Q(0) = 8 → UB = 8.
Gap = UB − LB = 0 ✓ Converged! Optimal: y=0, x=8, cost=8.`,
lbHistory: [-999, 8],
ubHistory: [8, 8],
cuts: ['η ≥ 8 − 3y'],
converged: true
}
];
let current = 0;
const canvas = document.getElementById('bdCanvas');
const ctx = canvas.getContext('2d');
const logEl = document.getElementById('bdLog');
const statusEl = document.getElementById('bdStatus');
const stepBtn = document.getElementById('bdStep');
const runBtn = document.getElementById('bdRun');
const resetBtn = document.getElementById('bdReset');
function drawChart(state) {
const W = canvas.width, H = canvas.height;
ctx.clearRect(0, 0, W, H);
const pad = { left: 52, right: 20, top: 18, bottom: 36 };
const cW = W - pad.left - pad.right;
const cH = H - pad.top - pad.bottom;
// y-axis: -12 to 12 display range
const yMin = -12, yMax = 12;
const toY = v => pad.top + cH * (1 - (v - yMin) / (yMax - yMin));
const toX = i => pad.left + (i / (STEPS.length - 1 + 0.5)) * cW;
// Grid lines
ctx.strokeStyle = '#e0e6ed';
ctx.lineWidth = 1;
for (let v = yMin; v <= yMax; v += 4) {
const y = toY(v);
ctx.beginPath(); ctx.moveTo(pad.left, y); ctx.lineTo(pad.left + cW, y); ctx.stroke();
ctx.fillStyle = '#888';
ctx.font = '11px system-ui';
ctx.textAlign = 'right';
ctx.fillText(v === -12 ? '−∞' : v, pad.left - 4, y + 4);
}
// Axes
ctx.strokeStyle = '#bcc5d0';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(pad.left, pad.top);
ctx.lineTo(pad.left, pad.top + cH);
ctx.lineTo(pad.left + cW, pad.top + cH);
ctx.stroke();
// X-axis labels
ctx.fillStyle = '#555';
ctx.font = '11px system-ui';
ctx.textAlign = 'center';
for (let i = 1; i <= state.iter; i++) {
const x = toX(i - 0.5);
ctx.fillText(fmt(`Iter %i%`, { i: i }), x, pad.top + cH + 16);
}
if (state.lbHistory.length === 0) {
ctx.fillStyle = '#aaa';
ctx.font = '13px system-ui';
ctx.textAlign = 'center';
ctx.fillText(`Press Step to begin`, pad.left + cW / 2, pad.top + cH / 2);
return;
}
const n = state.lbHistory.length;
// Draw LB bars (blue)
for (let i = 0; i < n; i++) {
const rawLB = state.lbHistory[i];
const displayLB = Math.max(rawLB, yMin);
const x = toX(i + 0.5);
const barW = cW / (STEPS.length) * 0.35;
const yBot = toY(0);
const yTop = toY(displayLB);
ctx.fillStyle = rawLB < yMin ? '#a0b8e8' : '#3b6fd4';
ctx.fillRect(x - barW - 1, Math.min(yBot, yTop), barW, Math.abs(yBot - yTop) || 4);
// Label
ctx.fillStyle = '#1a3870';
ctx.font = 'bold 11px system-ui';
ctx.textAlign = 'center';
ctx.fillText(rawLB === -999 ? '−∞' : rawLB, x - barW / 2 - 1, Math.min(yBot, yTop) - 4);
}
// Draw UB bars (orange)
for (let i = 0; i < n; i++) {
const ub = state.ubHistory[i];
const x = toX(i + 0.5);
const barW = cW / (STEPS.length) * 0.35;
const yBot = toY(0);
const yTop = toY(ub);
ctx.fillStyle = '#e07b2a';
ctx.fillRect(x + 1, Math.min(yBot, yTop), barW, Math.abs(yBot - yTop) || 4);
// Label
ctx.fillStyle = '#7a3800';
ctx.font = 'bold 11px system-ui';
ctx.textAlign = 'center';
ctx.fillText(ub, x + barW / 2 + 1, Math.min(yBot, yTop) - 4);
}
// Optimal line
ctx.strokeStyle = '#0a7a30';
ctx.setLineDash([4, 4]);
ctx.lineWidth = 1.5;
const optY = toY(8);
ctx.beginPath();
ctx.moveTo(pad.left, optY);
ctx.lineTo(pad.left + cW, optY);
ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = '#0a7a30';
ctx.font = '11px system-ui';
ctx.textAlign = 'left';
ctx.fillText(`Optimal=8`, pad.left + 3, optY - 4);
}
function updateUI() {
const state = STEPS[current];
logEl.innerHTML = state.desc.replace(/\n/g, '<br>');
if (state.cuts.length > 0) {
logEl.innerHTML += fmt(`<br><strong>Active cuts:</strong> %cuts%`, { cuts: state.cuts.join('; ') });
}
statusEl.textContent = state.converged ? fmt(`✓ Converged — optimal solution found in %iter% iterations!`, { iter: state.iter }) : '';
stepBtn.disabled = state.converged || current >= STEPS.length - 1;
runBtn.disabled = state.converged || current >= STEPS.length - 1;
drawChart(state);
}
stepBtn.addEventListener('click', () => {
if (current < STEPS.length - 1) { current++; updateUI(); }
});
runBtn.addEventListener('click', () => {
current = STEPS.length - 1;
updateUI();
});
resetBtn.addEventListener('click', () => {
current = 0;
updateUI();
});
updateUI();
<div class="bd-wrap">
<div class="bd-problem">
<strong>Problem:</strong> minimize <em>c·y + Q(y)</em><br>
where <em>y ∈ {0,1}</em> (master binary) and <em>Q(y) = min x</em> s.t.
<em>x ≥ a·y + b</em>, <em>x ≥ 0</em>
<br><small>Scenario A (y=0): a=0, b=8 | Scenario B (y=1): a=-3, b=5</small>
</div>
<div class="bd-chart-area">
<canvas id="bdCanvas" width="480" height="220"></canvas>
</div>
<div class="bd-legend">
<span class="dot blue"></span> Master lower bound
<span class="dot orange"></span> Subproblem upper bound
</div>
<div class="bd-log" id="bdLog"><em>Press Step to start.</em></div>
<div class="bd-status" id="bdStatus"></div>
<div class="bd-btns">
<button id="bdStep">Step</button>
<button id="bdRun">Run to convergence</button>
<button id="bdReset" class="ghost">Reset</button>
</div>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; margin: 0; color: #1a2535; }
.bd-wrap { max-width: 520px; margin: 0 auto; padding: .5rem .7rem; }
.bd-problem { background: #f0f4f8; border-left: 3px solid #3b6fd4; padding: .55rem .75rem;
border-radius: 6px; font-size: .83rem; line-height: 1.6; margin-bottom: .7rem; }
.bd-problem strong { font-size: .9rem; }
.bd-chart-area { width: 100%; overflow: hidden; margin-bottom: .4rem; }
canvas { display: block; width: 100%; height: auto; border-radius: 8px;
background: #fafbfc; border: 1px solid #dde3ea; }
.bd-legend { font-size: .78rem; color: #555; margin-bottom: .5rem; }
.dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; margin-right: 3px; vertical-align: middle; }
.dot.blue { background: #3b6fd4; }
.dot.orange { background: #e07b2a; }
.bd-log { font-size: .82rem; min-height: 3.4em; background: #f7f9fb; border: 1px solid #dde3ea;
border-radius: 6px; padding: .4rem .6rem; line-height: 1.55; margin-bottom: .45rem; color: #2a3d55; }
.bd-status { font-weight: 700; font-size: .95rem; min-height: 1.5em; color: #0a7a30; margin-bottom: .35rem; }
.bd-btns { display: flex; gap: .5rem; flex-wrap: wrap; }
button { font: 600 14px system-ui; padding: .42rem .85rem; border-radius: 7px; cursor: pointer;
border: 1px solid #3b6fd4; background: #3b6fd4; color: #fff; }
button.ghost { background: #fff; color: #3b6fd4; }
button:disabled { opacity: .45; cursor: default; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Benders Decomposition demo — tiny MIP:
// min 5*y + Q(y) y in {0,1}
// Q(y) = min x s.t. x >= -3*y + 8, x >= 0
//
// When y=0: subproblem min x s.t. x>=8, x>=0 => Q(0)=8 total=8
// When y=1: subproblem min x s.t. x>=5, x>=0 => Q(1)=5 total=10
// Optimal: y=0, x=8, total=8.
//
// Master starts with eta >= -Inf (modeled as -999).
// Iteration 1: master solves relaxed -> picks y=0 (lower c*y=0), eta=-999 (unconstrained).
// Master obj = 0 + (-999) = -999. Lower bound = -999.
// Subproblem for y=0: Q(0)=8. Upper bound = 0+8=8.
// Dual of subproblem LP: x >= 8, x>=0 => dual multiplier lambda=1 on x>=8.
// Optimality cut: eta >= Q(0) + lambda*(-3)*(y - 0) = 8 - 3*y (linearized).
// Cut: eta >= 8 - 3*y.
//
// Iteration 2: master with cut eta>=8-3y, min 5y+eta.
// y=0 => eta>=8 => obj=8. y=1 => eta>=5 => obj=10. Master picks y=0,eta=8. LB=8.
// Subproblem y=0: Q(0)=8. UB=8. Gap=0 -> converged.
//
// We’ll show 3 steps: init, iter1, iter2 (converged).
const STEPS = [
{
iter: 0,
desc: `Initial state. The master has no cuts yet — eta is unbounded below. Master lower bound = −∞ (shown as −999). No subproblem evaluated yet.`,
lbHistory: [],
ubHistory: [],
cuts: [],
converged: false
},
{
iter: 1,
desc: `Iteration 1 — Master (no cuts): picks y=0, eta=−999 → LB = −999.
Subproblem (y=0): Q(0) = 8 → UB = 5·0 + 8 = 8.
Optimality cut added: η ≥ 8 − 3y (derived from dual multiplier λ=1).`,
lbHistory: [-999],
ubHistory: [8],
cuts: ['η ≥ 8 − 3y'],
converged: false
},
{
iter: 2,
desc: `Iteration 2 — Master (with cut η≥8−3y): y=0→obj=8, y=1→obj=10 → picks y=0,η=8 → LB = 8.
Subproblem (y=0): Q(0) = 8 → UB = 8.
Gap = UB − LB = 0 ✓ Converged! Optimal: y=0, x=8, cost=8.`,
lbHistory: [-999, 8],
ubHistory: [8, 8],
cuts: ['η ≥ 8 − 3y'],
converged: true
}
];
let current = 0;
const canvas = document.getElementById('bdCanvas');
const ctx = canvas.getContext('2d');
const logEl = document.getElementById('bdLog');
const statusEl = document.getElementById('bdStatus');
const stepBtn = document.getElementById('bdStep');
const runBtn = document.getElementById('bdRun');
const resetBtn = document.getElementById('bdReset');
function drawChart(state) {
const W = canvas.width, H = canvas.height;
ctx.clearRect(0, 0, W, H);
const pad = { left: 52, right: 20, top: 18, bottom: 36 };
const cW = W - pad.left - pad.right;
const cH = H - pad.top - pad.bottom;
// y-axis: -12 to 12 display range
const yMin = -12, yMax = 12;
const toY = v => pad.top + cH * (1 - (v - yMin) / (yMax - yMin));
const toX = i => pad.left + (i / (STEPS.length - 1 + 0.5)) * cW;
// Grid lines
ctx.strokeStyle = '#e0e6ed';
ctx.lineWidth = 1;
for (let v = yMin; v <= yMax; v += 4) {
const y = toY(v);
ctx.beginPath(); ctx.moveTo(pad.left, y); ctx.lineTo(pad.left + cW, y); ctx.stroke();
ctx.fillStyle = '#888';
ctx.font = '11px system-ui';
ctx.textAlign = 'right';
ctx.fillText(v === -12 ? '−∞' : v, pad.left - 4, y + 4);
}
// Axes
ctx.strokeStyle = '#bcc5d0';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(pad.left, pad.top);
ctx.lineTo(pad.left, pad.top + cH);
ctx.lineTo(pad.left + cW, pad.top + cH);
ctx.stroke();
// X-axis labels
ctx.fillStyle = '#555';
ctx.font = '11px system-ui';
ctx.textAlign = 'center';
for (let i = 1; i <= state.iter; i++) {
const x = toX(i - 0.5);
ctx.fillText(fmt(`Iter %i%`, { i: i }), x, pad.top + cH + 16);
}
if (state.lbHistory.length === 0) {
ctx.fillStyle = '#aaa';
ctx.font = '13px system-ui';
ctx.textAlign = 'center';
ctx.fillText(`Press Step to begin`, pad.left + cW / 2, pad.top + cH / 2);
return;
}
const n = state.lbHistory.length;
// Draw LB bars (blue)
for (let i = 0; i < n; i++) {
const rawLB = state.lbHistory[i];
const displayLB = Math.max(rawLB, yMin);
const x = toX(i + 0.5);
const barW = cW / (STEPS.length) * 0.35;
const yBot = toY(0);
const yTop = toY(displayLB);
ctx.fillStyle = rawLB < yMin ? '#a0b8e8' : '#3b6fd4';
ctx.fillRect(x - barW - 1, Math.min(yBot, yTop), barW, Math.abs(yBot - yTop) || 4);
// Label
ctx.fillStyle = '#1a3870';
ctx.font = 'bold 11px system-ui';
ctx.textAlign = 'center';
ctx.fillText(rawLB === -999 ? '−∞' : rawLB, x - barW / 2 - 1, Math.min(yBot, yTop) - 4);
}
// Draw UB bars (orange)
for (let i = 0; i < n; i++) {
const ub = state.ubHistory[i];
const x = toX(i + 0.5);
const barW = cW / (STEPS.length) * 0.35;
const yBot = toY(0);
const yTop = toY(ub);
ctx.fillStyle = '#e07b2a';
ctx.fillRect(x + 1, Math.min(yBot, yTop), barW, Math.abs(yBot - yTop) || 4);
// Label
ctx.fillStyle = '#7a3800';
ctx.font = 'bold 11px system-ui';
ctx.textAlign = 'center';
ctx.fillText(ub, x + barW / 2 + 1, Math.min(yBot, yTop) - 4);
}
// Optimal line
ctx.strokeStyle = '#0a7a30';
ctx.setLineDash([4, 4]);
ctx.lineWidth = 1.5;
const optY = toY(8);
ctx.beginPath();
ctx.moveTo(pad.left, optY);
ctx.lineTo(pad.left + cW, optY);
ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle = '#0a7a30';
ctx.font = '11px system-ui';
ctx.textAlign = 'left';
ctx.fillText(`Optimal=8`, pad.left + 3, optY - 4);
}
function updateUI() {
const state = STEPS[current];
logEl.innerHTML = state.desc.replace(/\n/g, '<br>');
if (state.cuts.length > 0) {
logEl.innerHTML += fmt(`<br><strong>Active cuts:</strong> %cuts%`, { cuts: state.cuts.join('; ') });
}
statusEl.textContent = state.converged ? fmt(`✓ Converged — optimal solution found in %iter% iterations!`, { iter: state.iter }) : '';
stepBtn.disabled = state.converged || current >= STEPS.length - 1;
runBtn.disabled = state.converged || current >= STEPS.length - 1;
drawChart(state);
}
stepBtn.addEventListener('click', () => {
if (current < STEPS.length - 1) { current++; updateUI(); }
});
runBtn.addEventListener('click', () => {
current = STEPS.length - 1;
updateUI();
});
resetBtn.addEventListener('click', () => {
current = 0;
updateUI();
});
updateUI();
Comments
Loading comments...