Each panel below represents a data shard on a different machine. Add words to each shard — the shard builds its own HyperLogLog sketch locally. Then press Merge sketches to combine them with the element-wise maximum and read the estimated distinct count for the union.
<!-- Two-shard HyperLogLog merge demo: each shard has its own sketch, merge = element-wise max -->
<div class="app">
<div class="shards">
<div class="shard" id="shard-a">
<div class="shard-title">Shard A</div>
<div class="input-row">
<input id="input-a" type="text" placeholder="Type a word…" maxlength="40" />
<button id="btn-add-a" type="button">Add</button>
</div>
<div class="word-list" id="words-a"></div>
<div class="sketch-label">Registers (M=16): <span class="reg-count" id="reg-count-a">—</span></div>
<div class="regs" id="regs-a"></div>
<div class="est-row">Local HLL estimate: <strong id="est-a">—</strong></div>
</div>
<div class="shard" id="shard-b">
<div class="shard-title">Shard B</div>
<div class="input-row">
<input id="input-b" type="text" placeholder="Type a word…" maxlength="40" />
<button id="btn-add-b" type="button">Add</button>
</div>
<div class="word-list" id="words-b"></div>
<div class="sketch-label">Registers (M=16): <span class="reg-count" id="reg-count-b">—</span></div>
<div class="regs" id="regs-b"></div>
<div class="est-row">Local HLL estimate: <strong id="est-b">—</strong></div>
</div>
</div>
<div class="merge-section">
<button id="btn-merge" type="button" class="primary">Merge sketches</button>
<button id="btn-reset" type="button" class="ghost">Reset</button>
</div>
<div class="result-box" id="result-box" hidden>
<div class="result-line">Merged registers (element-wise max): <span class="reg-count" id="reg-count-m">—</span></div>
<div class="regs" id="regs-m"></div>
<div class="result-stats">
<div>True distinct count: <strong id="true-count">—</strong></div>
<div>HLL merged estimate: <strong id="hll-est">—</strong></div>
<div>Error: <strong id="hll-error">—</strong></div>
</div>
</div>
</div>
/* Layout for the two-shard HyperLogLog merge demo */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; font-size: .9rem; }
.app { display: flex; flex-direction: column; gap: .8rem; padding: .4rem 0; }
.shards { display: grid; grid-template-columns: 1fr 1fr; gap: .8rem; }
@media (max-width: 520px) { .shards { grid-template-columns: 1fr; } }
.shard { border: 1px solid #cdd9e3; border-radius: 10px; padding: .7rem .8rem; background: #f5f8fa; }
.shard-title { font-weight: 700; font-size: 1rem; margin-bottom: .5rem; color: #1d3557; }
.input-row { display: flex; gap: .4rem; margin-bottom: .5rem; }
.input-row input { flex: 1; padding: .35rem .6rem; border: 1px solid #adb1b8; border-radius: 7px; font-size: .88rem; }
.input-row button { padding: .35rem .7rem; background: #1d3557; color: #fff; border: none; border-radius: 7px; cursor: pointer; font-size: .85rem; }
.word-list { display: flex; flex-wrap: wrap; gap: .3rem; min-height: 1.6rem; margin-bottom: .4rem; }
.word-tag { background: #dde8f0; border-radius: 5px; padding: .1rem .45rem; font-size: .8rem; color: #1d3557; }
.word-tag.dup { opacity: .5; text-decoration: line-through; }
.sketch-label { font-size: .78rem; color: #666; margin-bottom: .2rem; }
.reg-count { font-weight: 600; }
.regs { display: flex; flex-wrap: wrap; gap: 2px; margin-bottom: .4rem; }
.reg { width: 14px; height: 14px; border-radius: 3px; background: #d0dce6; display: inline-flex; align-items: center; justify-content: center; font-size: 8px; color: #fff; font-weight: 700; }
.reg.lit { background: #457b9d; }
.reg.high { background: #e63946; }
.est-row { font-size: .82rem; color: #555; }
.merge-section { display: flex; gap: .5rem; }
button.primary { background: #1d3557; color: #fff; border: none; border-radius: 8px; padding: .45rem 1rem; font: 600 .9rem system-ui; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; border: 1px solid #1d3557; border-radius: 8px; padding: .45rem .9rem; font: 600 .9rem system-ui; cursor: pointer; }
.result-box { border: 2px solid #457b9d; border-radius: 10px; padding: .7rem .9rem; background: #e8f4fb; }
.result-line { font-size: .78rem; color: #444; margin-bottom: .25rem; }
.result-stats { display: flex; flex-wrap: wrap; gap: .6rem 1.2rem; margin-top: .4rem; font-size: .88rem; }
.result-stats strong { color: #1d3557; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// HyperLogLog cardinality estimator with M registers.
// Each register stores the max leading-zero position seen in its bucket.
const M = 16; // 16 buckets — small enough to see the registers visually
const MASK = M - 1;
// FNV-1a 32-bit hash: fast, uniform, deterministic
function hash32(str) {
let h = 0x811c9dc5;
for (let i = 0; i < str.length; i++) {
h ^= str.charCodeAt(i);
h = (h * 0x01000193) >>> 0;
}
return h;
}
// rho(bits): position of the leftmost 1-bit (1-indexed from MSB)
function rho(bits) {
if (bits === 0) return 32;
let pos = 1;
while ((bits & 0x80000000) === 0) { bits <<= 1; pos++; }
return pos;
}
// Create a zeroed sketch
function newSketch() { return new Uint8Array(M); }
// Add an item to a sketch: hash, pick bucket, update register with max rho
function sketchAdd(regs, item) {
const h = hash32(item);
const j = h & MASK;
const w = h >>> 4; // remaining bits after taking the bucket index
const r = rho(w);
if (r > regs[j]) regs[j] = r;
}
// Merge two sketches: element-wise max — the core of the algorithm
function sketchMerge(a, b) {
const m = new Uint8Array(M);
for (let j = 0; j < M; j++) m[j] = Math.max(a[j], b[j]);
return m;
}
// Estimate cardinality via harmonic mean of 2^(-register)
function estimate(regs) {
const alpha = 0.7213 / (1 + 1.079 / M);
let sum = 0;
for (let j = 0; j < M; j++) sum += Math.pow(2, -regs[j]);
return Math.round(alpha * M * M / sum);
}
// Per-shard state: word history, unique-word set, register array
const stateA = { words: [], set: new Set(), regs: newSketch() };
const stateB = { words: [], set: new Set(), regs: newSketch() };
function addWord(state, word, listId, regsId, estId, cntId) {
const w = word.trim().toLowerCase();
if (!w) return;
sketchAdd(state.regs, w);
const isDup = state.set.has(w);
if (!isDup) state.set.add(w);
state.words.push({ w, isDup });
renderWords(state, listId);
renderRegs(state.regs, regsId, cntId);
document.getElementById(estId).textContent = estimate(state.regs);
}
function renderWords(state, listId) {
const el = document.getElementById(listId);
el.innerHTML = '';
state.words.forEach(({ w, isDup }) => {
const t = document.createElement('span');
t.className = 'word-tag' + (isDup ? ' dup' : '');
t.textContent = w;
el.appendChild(t);
});
}
function renderRegs(regs, regsId, cntId) {
const el = document.getElementById(regsId);
const max = Math.max(...regs, 1);
el.innerHTML = '';
regs.forEach(v => {
const d = document.createElement('div');
d.className = 'reg' + (v > 0 ? (v >= max ? ' high' : ' lit') : '');
d.textContent = v || '';
el.appendChild(d);
});
if (cntId) document.getElementById(cntId).textContent = M;
}
function doMerge() {
const merged = sketchMerge(stateA.regs, stateB.regs);
const unionSet = new Set([...stateA.set, ...stateB.set]);
const trueCount = unionSet.size;
const hllEst = estimate(merged);
const err = trueCount > 0 ? Math.abs(hllEst - trueCount) / trueCount * 100 : 0;
renderRegs(merged, 'regs-m', 'reg-count-m');
document.getElementById('true-count').textContent = trueCount;
document.getElementById('hll-est').textContent = hllEst;
document.getElementById('hll-error').textContent = i18nFmt('%pct%%', { pct: err.toFixed(1) });
document.getElementById('result-box').hidden = false;
}
function doReset() {
stateA.words = []; stateA.set.clear(); stateA.regs = newSketch();
stateB.words = []; stateB.set.clear(); stateB.regs = newSketch();
['words-a','words-b'].forEach(id => document.getElementById(id).innerHTML = '');
['regs-a','regs-b'].forEach(id => { document.getElementById(id).innerHTML = ''; });
['reg-count-a','reg-count-b','est-a','est-b'].forEach(id => document.getElementById(id).textContent = '—');
document.getElementById('result-box').hidden = true;
}
function setupInput(inputId, btnId, state, listId, regsId, estId, cntId) {
const inp = document.getElementById(inputId);
const btn = document.getElementById(btnId);
btn.addEventListener('click', () => { addWord(state, inp.value, listId, regsId, estId, cntId); inp.value = ''; inp.focus(); });
inp.addEventListener('keydown', e => { if (e.key === 'Enter') btn.click(); });
}
setupInput('input-a', 'btn-add-a', stateA, 'words-a', 'regs-a', 'est-a', 'reg-count-a');
setupInput('input-b', 'btn-add-b', stateB, 'words-b', 'regs-b', 'est-b', 'reg-count-b');
document.getElementById('btn-merge').addEventListener('click', doMerge);
document.getElementById('btn-reset').addEventListener('click', doReset);
// Seed both shards with sample words so the demo starts populated
const seedA = ['apple','banana','cherry','date','elderberry','fig','grape','honeydew'];
const seedB = ['apple','grape','kiwi','lemon','mango','nectarine','orange','papaya'];
seedA.forEach(w => addWord(stateA, w, 'words-a', 'regs-a', 'est-a', 'reg-count-a'));
seedB.forEach(w => addWord(stateB, w, 'words-b', 'regs-b', 'est-b', 'reg-count-b'));
<!-- Two-shard HyperLogLog merge demo: each shard has its own sketch, merge = element-wise max -->
<div class="app">
<div class="shards">
<div class="shard" id="shard-a">
<div class="shard-title">Shard A</div>
<div class="input-row">
<input id="input-a" type="text" placeholder="Type a word…" maxlength="40" />
<button id="btn-add-a" type="button">Add</button>
</div>
<div class="word-list" id="words-a"></div>
<div class="sketch-label">Registers (M=16): <span class="reg-count" id="reg-count-a">—</span></div>
<div class="regs" id="regs-a"></div>
<div class="est-row">Local HLL estimate: <strong id="est-a">—</strong></div>
</div>
<div class="shard" id="shard-b">
<div class="shard-title">Shard B</div>
<div class="input-row">
<input id="input-b" type="text" placeholder="Type a word…" maxlength="40" />
<button id="btn-add-b" type="button">Add</button>
</div>
<div class="word-list" id="words-b"></div>
<div class="sketch-label">Registers (M=16): <span class="reg-count" id="reg-count-b">—</span></div>
<div class="regs" id="regs-b"></div>
<div class="est-row">Local HLL estimate: <strong id="est-b">—</strong></div>
</div>
</div>
<div class="merge-section">
<button id="btn-merge" type="button" class="primary">Merge sketches</button>
<button id="btn-reset" type="button" class="ghost">Reset</button>
</div>
<div class="result-box" id="result-box" hidden>
<div class="result-line">Merged registers (element-wise max): <span class="reg-count" id="reg-count-m">—</span></div>
<div class="regs" id="regs-m"></div>
<div class="result-stats">
<div>True distinct count: <strong id="true-count">—</strong></div>
<div>HLL merged estimate: <strong id="hll-est">—</strong></div>
<div>Error: <strong id="hll-error">—</strong></div>
</div>
</div>
</div>
/* Layout for the two-shard HyperLogLog merge demo */
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; color: #222; margin: 0; font-size: .9rem; }
.app { display: flex; flex-direction: column; gap: .8rem; padding: .4rem 0; }
.shards { display: grid; grid-template-columns: 1fr 1fr; gap: .8rem; }
@media (max-width: 520px) { .shards { grid-template-columns: 1fr; } }
.shard { border: 1px solid #cdd9e3; border-radius: 10px; padding: .7rem .8rem; background: #f5f8fa; }
.shard-title { font-weight: 700; font-size: 1rem; margin-bottom: .5rem; color: #1d3557; }
.input-row { display: flex; gap: .4rem; margin-bottom: .5rem; }
.input-row input { flex: 1; padding: .35rem .6rem; border: 1px solid #adb1b8; border-radius: 7px; font-size: .88rem; }
.input-row button { padding: .35rem .7rem; background: #1d3557; color: #fff; border: none; border-radius: 7px; cursor: pointer; font-size: .85rem; }
.word-list { display: flex; flex-wrap: wrap; gap: .3rem; min-height: 1.6rem; margin-bottom: .4rem; }
.word-tag { background: #dde8f0; border-radius: 5px; padding: .1rem .45rem; font-size: .8rem; color: #1d3557; }
.word-tag.dup { opacity: .5; text-decoration: line-through; }
.sketch-label { font-size: .78rem; color: #666; margin-bottom: .2rem; }
.reg-count { font-weight: 600; }
.regs { display: flex; flex-wrap: wrap; gap: 2px; margin-bottom: .4rem; }
.reg { width: 14px; height: 14px; border-radius: 3px; background: #d0dce6; display: inline-flex; align-items: center; justify-content: center; font-size: 8px; color: #fff; font-weight: 700; }
.reg.lit { background: #457b9d; }
.reg.high { background: #e63946; }
.est-row { font-size: .82rem; color: #555; }
.merge-section { display: flex; gap: .5rem; }
button.primary { background: #1d3557; color: #fff; border: none; border-radius: 8px; padding: .45rem 1rem; font: 600 .9rem system-ui; cursor: pointer; }
button.ghost { background: #fff; color: #1d3557; border: 1px solid #1d3557; border-radius: 8px; padding: .45rem .9rem; font: 600 .9rem system-ui; cursor: pointer; }
.result-box { border: 2px solid #457b9d; border-radius: 10px; padding: .7rem .9rem; background: #e8f4fb; }
.result-line { font-size: .78rem; color: #444; margin-bottom: .25rem; }
.result-stats { display: flex; flex-wrap: wrap; gap: .6rem 1.2rem; margin-top: .4rem; font-size: .88rem; }
.result-stats strong { color: #1d3557; }
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// HyperLogLog cardinality estimator with M registers.
// Each register stores the max leading-zero position seen in its bucket.
const M = 16; // 16 buckets — small enough to see the registers visually
const MASK = M - 1;
// FNV-1a 32-bit hash: fast, uniform, deterministic
function hash32(str) {
let h = 0x811c9dc5;
for (let i = 0; i < str.length; i++) {
h ^= str.charCodeAt(i);
h = (h * 0x01000193) >>> 0;
}
return h;
}
// rho(bits): position of the leftmost 1-bit (1-indexed from MSB)
function rho(bits) {
if (bits === 0) return 32;
let pos = 1;
while ((bits & 0x80000000) === 0) { bits <<= 1; pos++; }
return pos;
}
// Create a zeroed sketch
function newSketch() { return new Uint8Array(M); }
// Add an item to a sketch: hash, pick bucket, update register with max rho
function sketchAdd(regs, item) {
const h = hash32(item);
const j = h & MASK;
const w = h >>> 4; // remaining bits after taking the bucket index
const r = rho(w);
if (r > regs[j]) regs[j] = r;
}
// Merge two sketches: element-wise max — the core of the algorithm
function sketchMerge(a, b) {
const m = new Uint8Array(M);
for (let j = 0; j < M; j++) m[j] = Math.max(a[j], b[j]);
return m;
}
// Estimate cardinality via harmonic mean of 2^(-register)
function estimate(regs) {
const alpha = 0.7213 / (1 + 1.079 / M);
let sum = 0;
for (let j = 0; j < M; j++) sum += Math.pow(2, -regs[j]);
return Math.round(alpha * M * M / sum);
}
// Per-shard state: word history, unique-word set, register array
const stateA = { words: [], set: new Set(), regs: newSketch() };
const stateB = { words: [], set: new Set(), regs: newSketch() };
function addWord(state, word, listId, regsId, estId, cntId) {
const w = word.trim().toLowerCase();
if (!w) return;
sketchAdd(state.regs, w);
const isDup = state.set.has(w);
if (!isDup) state.set.add(w);
state.words.push({ w, isDup });
renderWords(state, listId);
renderRegs(state.regs, regsId, cntId);
document.getElementById(estId).textContent = estimate(state.regs);
}
function renderWords(state, listId) {
const el = document.getElementById(listId);
el.innerHTML = '';
state.words.forEach(({ w, isDup }) => {
const t = document.createElement('span');
t.className = 'word-tag' + (isDup ? ' dup' : '');
t.textContent = w;
el.appendChild(t);
});
}
function renderRegs(regs, regsId, cntId) {
const el = document.getElementById(regsId);
const max = Math.max(...regs, 1);
el.innerHTML = '';
regs.forEach(v => {
const d = document.createElement('div');
d.className = 'reg' + (v > 0 ? (v >= max ? ' high' : ' lit') : '');
d.textContent = v || '';
el.appendChild(d);
});
if (cntId) document.getElementById(cntId).textContent = M;
}
function doMerge() {
const merged = sketchMerge(stateA.regs, stateB.regs);
const unionSet = new Set([...stateA.set, ...stateB.set]);
const trueCount = unionSet.size;
const hllEst = estimate(merged);
const err = trueCount > 0 ? Math.abs(hllEst - trueCount) / trueCount * 100 : 0;
renderRegs(merged, 'regs-m', 'reg-count-m');
document.getElementById('true-count').textContent = trueCount;
document.getElementById('hll-est').textContent = hllEst;
document.getElementById('hll-error').textContent = i18nFmt('%pct%%', { pct: err.toFixed(1) });
document.getElementById('result-box').hidden = false;
}
function doReset() {
stateA.words = []; stateA.set.clear(); stateA.regs = newSketch();
stateB.words = []; stateB.set.clear(); stateB.regs = newSketch();
['words-a','words-b'].forEach(id => document.getElementById(id).innerHTML = '');
['regs-a','regs-b'].forEach(id => { document.getElementById(id).innerHTML = ''; });
['reg-count-a','reg-count-b','est-a','est-b'].forEach(id => document.getElementById(id).textContent = '—');
document.getElementById('result-box').hidden = true;
}
function setupInput(inputId, btnId, state, listId, regsId, estId, cntId) {
const inp = document.getElementById(inputId);
const btn = document.getElementById(btnId);
btn.addEventListener('click', () => { addWord(state, inp.value, listId, regsId, estId, cntId); inp.value = ''; inp.focus(); });
inp.addEventListener('keydown', e => { if (e.key === 'Enter') btn.click(); });
}
setupInput('input-a', 'btn-add-a', stateA, 'words-a', 'regs-a', 'est-a', 'reg-count-a');
setupInput('input-b', 'btn-add-b', stateB, 'words-b', 'regs-b', 'est-b', 'reg-count-b');
document.getElementById('btn-merge').addEventListener('click', doMerge);
document.getElementById('btn-reset').addEventListener('click', doReset);
// Seed both shards with sample words so the demo starts populated
const seedA = ['apple','banana','cherry','date','elderberry','fig','grape','honeydew'];
const seedB = ['apple','grape','kiwi','lemon','mango','nectarine','orange','papaya'];
seedA.forEach(w => addWord(stateA, w, 'words-a', 'regs-a', 'est-a', 'reg-count-a'));
seedB.forEach(w => addWord(stateB, w, 'words-b', 'regs-b', 'est-b', 'reg-count-b'));
Comments
Loading comments...