Below is a simplified CCA with three spectrum bands and four bidders. During the clock phase prices rise each round; each bidder holds demand as long as the price stays below their private value. When every band is wanted by exactly one bidder, the clock stops and the solver picks the maximum-revenue assignment.
<!-- Combinatorial Clock Auction demo: clock phase raises prices until demand meets supply, then WDP solver picks winners. -->
<div class="auction-wrap">
<div class="phase-label" id="phase-label">Clock Phase</div>
<div class="bands-row" id="bands-row"></div>
<div class="bidders-section">
<div class="section-title">Bidders</div>
<div id="bidder-cards"></div>
</div>
<div class="status-bar" id="status-bar"></div>
<div class="btns">
<button id="btn-next" type="button">Next Round</button>
<button id="btn-reset" type="button" class="ghost">Reset</button>
</div>
<div class="result-panel" id="result-panel" style="display:none">
<div class="result-title">Winning Assignment</div>
<div id="result-rows"></div>
<div class="result-revenue" id="result-revenue"></div>
</div>
</div>
/* Layout and style for the CCA demo */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; padding: .5rem; }
.auction-wrap { max-width: 520px; margin: 0 auto; }
.phase-label { font-size: .82rem; font-weight: 700; text-transform: uppercase;
letter-spacing: .06em; color: #1d3557; background: #dce8f5; border-radius: 6px;
padding: .28rem .7rem; display: inline-block; margin-bottom: .6rem; }
.bands-row { display: flex; gap: .6rem; margin-bottom: .8rem; flex-wrap: wrap; }
.band-card { flex: 1; min-width: 110px; border: 1.5px solid #cdd9e3; border-radius: 10px;
padding: .55rem .7rem; background: #f0f5fa; }
.band-name { font-weight: 700; font-size: .95rem; color: #1d3557; }
.band-price { font-size: 1.1rem; font-weight: 700; color: #0a7d33; margin: .1rem 0; }
.band-demand { font-size: .78rem; color: #555; }
.section-title { font-size: .8rem; font-weight: 700; text-transform: uppercase;
letter-spacing: .05em; color: #888; margin-bottom: .4rem; }
#bidder-cards { display: grid; grid-template-columns: 1fr 1fr; gap: .5rem; margin-bottom: .7rem; }
.bidder-card { border: 1.5px solid #e0e0e0; border-radius: 8px; padding: .45rem .6rem;
font-size: .85rem; background: #fafafa; }
.bidder-name { font-weight: 700; color: #333; margin-bottom: .2rem; }
.bidder-wants { color: #1d3557; font-size: .8rem; }
.bidder-card.dropped { opacity: .4; }
.bidder-card.won { border-color: #0a7d33; background: #eafbee; }
.status-bar { font-size: .95rem; font-weight: 600; min-height: 1.4em;
margin: .4rem 0; color: #555; }
.status-bar.alert { color: #c92f3c; }
.status-bar.ok { color: #0a7d33; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; margin-bottom: .8rem; }
button { font: 600 14px system-ui; padding: .42rem .9rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
button:disabled { opacity: .4; cursor: default; }
.result-panel { border: 2px solid #0a7d33; border-radius: 10px; padding: .8rem 1rem;
background: #eafbee; }
.result-title { font-weight: 700; font-size: .95rem; color: #0a7d33; margin-bottom: .5rem; }
.result-row { font-size: .88rem; margin-bottom: .25rem; }
.result-revenue { font-weight: 700; font-size: 1rem; margin-top: .5rem; border-top: 1px solid #b6dfc0;
padding-top: .4rem; color: #065220; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Winner Determination Problem (WDP): find a non-overlapping subset of bids that maximises total revenue. NP-hard in general.
// Clock phase: prices rise each round until each band is demanded by at most one bidder.
// Three spectrum bands, each with supply of 1 unit.
const BANDS = [
{ id: 'A', label: 'Band A (700 MHz)', supply: 1 },
{ id: 'B', label: 'Band B (800 MHz)', supply: 1 },
{ id: 'C', label: 'Band C (900 MHz)', supply: 1 }
];
// Four bidders, each with a private value for each band.
// Private values: bidder pays up to this price for the band.
const BIDDERS = [
{ id: 0, name: 'Alpha Mobile', values: { A: 120, B: 80, C: 50 } },
{ id: 1, name: 'Beta Wireless', values: { A: 90, B: 110, C: 70 } },
{ id: 2, name: 'Gamma Telecom', values: { A: 60, B: 65, C: 100 } },
{ id: 3, name: 'Delta Net', values: { A: 75, B: 95, C: 85 } }
];
const PRICE_STEP = 20;
const START_PRICE = 40;
let prices = {};
let round = 0;
let phase = 'clock';
let dropped = new Set();
let assignment = null;
function init() {
prices = {};
BANDS.forEach(b => { prices[b.id] = START_PRICE; });
round = 0;
phase = 'clock';
dropped = new Set();
assignment = null;
document.getElementById('result-panel').style.display = 'none';
document.getElementById('btn-next').disabled = false;
document.getElementById('btn-next').textContent = 'Next Round';
render();
setStatus('Press Next Round to start the clock.', '');
}
// A bidder demands a band iff their value meets or exceeds the current price and they have not dropped out.
function demandFor(bidderId, bandId) {
if (dropped.has(bidderId)) return false;
return BIDDERS[bidderId].values[bandId] >= prices[bandId];
}
function totalDemand(bandId) {
return BIDDERS.filter((_, i) => demandFor(i, bandId)).length;
}
function isCleared() {
return BANDS.every(b => totalDemand(b.id) <= 1);
}
// Brute-force WDP solver: try all 2^n subsets of active bidders and find the feasible assignment with highest revenue.
function solveWDP() {
// Enumerate all subsets of active bidders.
const active = BIDDERS.filter((_, i) => !dropped.has(i));
const n = active.length;
let best = { revenue: 0, picks: [] };
for (let mask = 0; mask < (1 << n); mask++) {
const chosen = active.filter((_, i) => mask & (1 << i));
const used = new Set();
let feasible = true;
let revenue = 0;
for (const b of chosen) {
// Assign the band that this bidder values most among still-available bands.
let allocBand = null;
for (const band of BANDS) {
if (!used.has(band.id) && b.values[band.id] >= prices[band.id]) {
if (allocBand === null || b.values[band.id] > b.values[allocBand]) allocBand = band.id;
}
}
if (!allocBand) { feasible = false; break; }
used.add(allocBand);
revenue += prices[allocBand];
}
if (feasible && revenue > best.revenue) best = { revenue, picks: chosen.map(b => ({ bidder: b.id, band: [...used][chosen.indexOf(b)] })) };
}
// Fallback: assign each band to its highest-valued active bidder (one band per bidder).
if (best.picks.length === 0) {
const usedB = new Set();
const picks = [];
for (const band of BANDS) {
let winner = null, winVal = -1;
for (const b of BIDDERS) {
if (!dropped.has(b.id) && b.values[band.id] >= prices[band.id] && b.values[band.id] > winVal) {
winner = b; winVal = b.values[band.id];
}
}
if (winner && !picks.some(p => p.bidder === winner.id)) {
picks.push({ bidder: winner.id, band: band.id });
usedB.add(band.id);
best.revenue += prices[band.id];
}
}
best.picks = picks;
}
return best;
}
function render() {
const bandsRow = document.getElementById('bands-row');
bandsRow.innerHTML = '';
BANDS.forEach(b => {
const card = document.createElement('div');
card.className = 'band-card';
const d = totalDemand(b.id);
card.innerHTML = `<div class="band-name">${b.label}</div>
<div class="band-price">${prices[b.id]}</div>
<div class="band-demand">${i18nFmt('%d% bidder(s) want this', { d })}</div>`;
bandsRow.appendChild(card);
});
const bidderCards = document.getElementById('bidder-cards');
bidderCards.innerHTML = '';
BIDDERS.forEach(b => {
const card = document.createElement('div');
const isDrop = dropped.has(b.id);
const isWon = assignment && assignment.picks.some(p => p.bidder === b.id);
card.className = 'bidder-card' + (isDrop ? ' dropped' : '') + (isWon ? ' won' : '');
const wants = BANDS.filter(band => demandFor(b.id, band.id)).map(band => band.label).join(', ') || 'no demand';
card.innerHTML = `<div class="bidder-name">${b.name}</div>
<div class="bidder-wants">${isDrop ? 'dropped out' : i18nFmt('wants: %wants%', { wants })}</div>`;
bidderCards.appendChild(card);
});
document.getElementById('phase-label').textContent =
phase === 'clock' ? 'Clock Phase' : 'Result';
}
function setStatus(msg, cls) {
const el = document.getElementById('status-bar');
el.textContent = msg;
el.className = 'status-bar' + (cls ? ' ' + cls : '');
}
function nextRound() {
if (phase !== 'clock') return;
round++;
// Bidders who want nothing at current prices drop out.
BIDDERS.forEach(b => {
if (!dropped.has(b.id)) {
const hasAny = BANDS.some(band => demandFor(b.id, band.id));
if (!hasAny) dropped.add(b.id);
}
});
if (isCleared()) {
phase = 'wdp';
document.getElementById('btn-next').disabled = true;
assignment = solveWDP();
render();
showResult();
return;
}
// Raise prices on over-demanded bands.
BANDS.forEach(b => {
if (totalDemand(b.id) > 1) prices[b.id] += PRICE_STEP;
});
render();
setStatus(i18nFmt('Round %r% — prices rise on contested bands.', { r: round }), '');
}
function showResult() {
const panel = document.getElementById('result-panel');
panel.style.display = 'block';
const rows = document.getElementById('result-rows');
rows.innerHTML = '';
if (assignment && assignment.picks.length > 0) {
assignment.picks.forEach(p => {
const b = BIDDERS[p.bidder];
const band = BANDS.find(x => x.id === p.band);
const div = document.createElement('div');
div.className = 'result-row';
div.textContent = i18nFmt('%name% wins %band% at $%price%', { name: b.name, band: band ? band.label : p.band, price: prices[p.band] });
rows.appendChild(div);
});
document.getElementById('result-revenue').textContent =
i18nFmt('Total revenue: $%total%', { total: assignment.revenue });
setStatus('Market cleared — winning assignment found.', 'ok');
} else {
setStatus('No feasible assignment found.', 'alert');
}
}
document.getElementById('btn-next').addEventListener('click', nextRound);
document.getElementById('btn-reset').addEventListener('click', init);
init();
<!-- Combinatorial Clock Auction demo: clock phase raises prices until demand meets supply, then WDP solver picks winners. -->
<div class="auction-wrap">
<div class="phase-label" id="phase-label">Clock Phase</div>
<div class="bands-row" id="bands-row"></div>
<div class="bidders-section">
<div class="section-title">Bidders</div>
<div id="bidder-cards"></div>
</div>
<div class="status-bar" id="status-bar"></div>
<div class="btns">
<button id="btn-next" type="button">Next Round</button>
<button id="btn-reset" type="button" class="ghost">Reset</button>
</div>
<div class="result-panel" id="result-panel" style="display:none">
<div class="result-title">Winning Assignment</div>
<div id="result-rows"></div>
<div class="result-revenue" id="result-revenue"></div>
</div>
</div>
/* Layout and style for the CCA demo */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; padding: .5rem; }
.auction-wrap { max-width: 520px; margin: 0 auto; }
.phase-label { font-size: .82rem; font-weight: 700; text-transform: uppercase;
letter-spacing: .06em; color: #1d3557; background: #dce8f5; border-radius: 6px;
padding: .28rem .7rem; display: inline-block; margin-bottom: .6rem; }
.bands-row { display: flex; gap: .6rem; margin-bottom: .8rem; flex-wrap: wrap; }
.band-card { flex: 1; min-width: 110px; border: 1.5px solid #cdd9e3; border-radius: 10px;
padding: .55rem .7rem; background: #f0f5fa; }
.band-name { font-weight: 700; font-size: .95rem; color: #1d3557; }
.band-price { font-size: 1.1rem; font-weight: 700; color: #0a7d33; margin: .1rem 0; }
.band-demand { font-size: .78rem; color: #555; }
.section-title { font-size: .8rem; font-weight: 700; text-transform: uppercase;
letter-spacing: .05em; color: #888; margin-bottom: .4rem; }
#bidder-cards { display: grid; grid-template-columns: 1fr 1fr; gap: .5rem; margin-bottom: .7rem; }
.bidder-card { border: 1.5px solid #e0e0e0; border-radius: 8px; padding: .45rem .6rem;
font-size: .85rem; background: #fafafa; }
.bidder-name { font-weight: 700; color: #333; margin-bottom: .2rem; }
.bidder-wants { color: #1d3557; font-size: .8rem; }
.bidder-card.dropped { opacity: .4; }
.bidder-card.won { border-color: #0a7d33; background: #eafbee; }
.status-bar { font-size: .95rem; font-weight: 600; min-height: 1.4em;
margin: .4rem 0; color: #555; }
.status-bar.alert { color: #c92f3c; }
.status-bar.ok { color: #0a7d33; }
.btns { display: flex; gap: .5rem; flex-wrap: wrap; margin-bottom: .8rem; }
button { font: 600 14px system-ui; padding: .42rem .9rem; border: 1px solid #1d3557;
background: #1d3557; color: #fff; border-radius: 8px; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; }
button:disabled { opacity: .4; cursor: default; }
.result-panel { border: 2px solid #0a7d33; border-radius: 10px; padding: .8rem 1rem;
background: #eafbee; }
.result-title { font-weight: 700; font-size: .95rem; color: #0a7d33; margin-bottom: .5rem; }
.result-row { font-size: .88rem; margin-bottom: .25rem; }
.result-revenue { font-weight: 700; font-size: 1rem; margin-top: .5rem; border-top: 1px solid #b6dfc0;
padding-top: .4rem; color: #065220; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Winner Determination Problem (WDP): find a non-overlapping subset of bids that maximises total revenue. NP-hard in general.
// Clock phase: prices rise each round until each band is demanded by at most one bidder.
// Three spectrum bands, each with supply of 1 unit.
const BANDS = [
{ id: 'A', label: 'Band A (700 MHz)', supply: 1 },
{ id: 'B', label: 'Band B (800 MHz)', supply: 1 },
{ id: 'C', label: 'Band C (900 MHz)', supply: 1 }
];
// Four bidders, each with a private value for each band.
// Private values: bidder pays up to this price for the band.
const BIDDERS = [
{ id: 0, name: 'Alpha Mobile', values: { A: 120, B: 80, C: 50 } },
{ id: 1, name: 'Beta Wireless', values: { A: 90, B: 110, C: 70 } },
{ id: 2, name: 'Gamma Telecom', values: { A: 60, B: 65, C: 100 } },
{ id: 3, name: 'Delta Net', values: { A: 75, B: 95, C: 85 } }
];
const PRICE_STEP = 20;
const START_PRICE = 40;
let prices = {};
let round = 0;
let phase = 'clock';
let dropped = new Set();
let assignment = null;
function init() {
prices = {};
BANDS.forEach(b => { prices[b.id] = START_PRICE; });
round = 0;
phase = 'clock';
dropped = new Set();
assignment = null;
document.getElementById('result-panel').style.display = 'none';
document.getElementById('btn-next').disabled = false;
document.getElementById('btn-next').textContent = 'Next Round';
render();
setStatus('Press Next Round to start the clock.', '');
}
// A bidder demands a band iff their value meets or exceeds the current price and they have not dropped out.
function demandFor(bidderId, bandId) {
if (dropped.has(bidderId)) return false;
return BIDDERS[bidderId].values[bandId] >= prices[bandId];
}
function totalDemand(bandId) {
return BIDDERS.filter((_, i) => demandFor(i, bandId)).length;
}
function isCleared() {
return BANDS.every(b => totalDemand(b.id) <= 1);
}
// Brute-force WDP solver: try all 2^n subsets of active bidders and find the feasible assignment with highest revenue.
function solveWDP() {
// Enumerate all subsets of active bidders.
const active = BIDDERS.filter((_, i) => !dropped.has(i));
const n = active.length;
let best = { revenue: 0, picks: [] };
for (let mask = 0; mask < (1 << n); mask++) {
const chosen = active.filter((_, i) => mask & (1 << i));
const used = new Set();
let feasible = true;
let revenue = 0;
for (const b of chosen) {
// Assign the band that this bidder values most among still-available bands.
let allocBand = null;
for (const band of BANDS) {
if (!used.has(band.id) && b.values[band.id] >= prices[band.id]) {
if (allocBand === null || b.values[band.id] > b.values[allocBand]) allocBand = band.id;
}
}
if (!allocBand) { feasible = false; break; }
used.add(allocBand);
revenue += prices[allocBand];
}
if (feasible && revenue > best.revenue) best = { revenue, picks: chosen.map(b => ({ bidder: b.id, band: [...used][chosen.indexOf(b)] })) };
}
// Fallback: assign each band to its highest-valued active bidder (one band per bidder).
if (best.picks.length === 0) {
const usedB = new Set();
const picks = [];
for (const band of BANDS) {
let winner = null, winVal = -1;
for (const b of BIDDERS) {
if (!dropped.has(b.id) && b.values[band.id] >= prices[band.id] && b.values[band.id] > winVal) {
winner = b; winVal = b.values[band.id];
}
}
if (winner && !picks.some(p => p.bidder === winner.id)) {
picks.push({ bidder: winner.id, band: band.id });
usedB.add(band.id);
best.revenue += prices[band.id];
}
}
best.picks = picks;
}
return best;
}
function render() {
const bandsRow = document.getElementById('bands-row');
bandsRow.innerHTML = '';
BANDS.forEach(b => {
const card = document.createElement('div');
card.className = 'band-card';
const d = totalDemand(b.id);
card.innerHTML = `<div class="band-name">${b.label}</div>
<div class="band-price">${prices[b.id]}</div>
<div class="band-demand">${i18nFmt('%d% bidder(s) want this', { d })}</div>`;
bandsRow.appendChild(card);
});
const bidderCards = document.getElementById('bidder-cards');
bidderCards.innerHTML = '';
BIDDERS.forEach(b => {
const card = document.createElement('div');
const isDrop = dropped.has(b.id);
const isWon = assignment && assignment.picks.some(p => p.bidder === b.id);
card.className = 'bidder-card' + (isDrop ? ' dropped' : '') + (isWon ? ' won' : '');
const wants = BANDS.filter(band => demandFor(b.id, band.id)).map(band => band.label).join(', ') || 'no demand';
card.innerHTML = `<div class="bidder-name">${b.name}</div>
<div class="bidder-wants">${isDrop ? 'dropped out' : i18nFmt('wants: %wants%', { wants })}</div>`;
bidderCards.appendChild(card);
});
document.getElementById('phase-label').textContent =
phase === 'clock' ? 'Clock Phase' : 'Result';
}
function setStatus(msg, cls) {
const el = document.getElementById('status-bar');
el.textContent = msg;
el.className = 'status-bar' + (cls ? ' ' + cls : '');
}
function nextRound() {
if (phase !== 'clock') return;
round++;
// Bidders who want nothing at current prices drop out.
BIDDERS.forEach(b => {
if (!dropped.has(b.id)) {
const hasAny = BANDS.some(band => demandFor(b.id, band.id));
if (!hasAny) dropped.add(b.id);
}
});
if (isCleared()) {
phase = 'wdp';
document.getElementById('btn-next').disabled = true;
assignment = solveWDP();
render();
showResult();
return;
}
// Raise prices on over-demanded bands.
BANDS.forEach(b => {
if (totalDemand(b.id) > 1) prices[b.id] += PRICE_STEP;
});
render();
setStatus(i18nFmt('Round %r% — prices rise on contested bands.', { r: round }), '');
}
function showResult() {
const panel = document.getElementById('result-panel');
panel.style.display = 'block';
const rows = document.getElementById('result-rows');
rows.innerHTML = '';
if (assignment && assignment.picks.length > 0) {
assignment.picks.forEach(p => {
const b = BIDDERS[p.bidder];
const band = BANDS.find(x => x.id === p.band);
const div = document.createElement('div');
div.className = 'result-row';
div.textContent = i18nFmt('%name% wins %band% at $%price%', { name: b.name, band: band ? band.label : p.band, price: prices[p.band] });
rows.appendChild(div);
});
document.getElementById('result-revenue').textContent =
i18nFmt('Total revenue: $%total%', { total: assignment.revenue });
setStatus('Market cleared — winning assignment found.', 'ok');
} else {
setStatus('No feasible assignment found.', 'alert');
}
}
document.getElementById('btn-next').addEventListener('click', nextRound);
document.getElementById('btn-reset').addEventListener('click', init);
init();
Notice what happens when two bidders both covet the same band right up to a high price: the solver must try every possible assignment to confirm which one gives the best total. With only a handful of bidders and bands this is instant; in a real FCC auction with hundreds of licences the search space is 2n and only heuristics keep it tractable.
Comments
Loading comments...