Type in the box below and watch PPM build its context model character by character. For each new symbol, the panel shows which context depth matched, the predicted probability of that symbol in that context, and the estimated bits needed to encode it (−log2 probability).
<div class="ppm-wrap">
<div class="input-row">
<label for="ppm-input" class="inp-label">Type text here:</label>
<input id="ppm-input" type="text" autocomplete="off" spellcheck="false"
placeholder='e.g. "abracadabra" or "the cat sat on the mat"' />
</div>
<div class="presets">
<span class="preset-label">Presets:</span>
<button class="preset" data-text="abracadabra">abracadabra</button>
<button class="preset" data-text="the cat sat on the mat">the cat sat on the mat</button>
<button class="preset" data-text="aaabbbaaabbb">aaabbbaaabbb</button>
<button class="preset" data-text="hello world hello world hello">hello world ×2</button>
</div>
<div class="summary" id="summary" style="display:none">
<span id="total-bits"></span>
<span id="bpc"></span>
</div>
<div class="log-head" id="log-head" style="display:none">
<span class="col-char">Char</span>
<span class="col-ctx">Context used</span>
<span class="col-depth">Order</span>
<span class="col-prob">Prob</span>
<span class="col-bits">Bits</span>
<span class="col-bar">Compression saving</span>
</div>
<div id="log"></div>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; margin: 0; color: #1a2030; background: #f7f9fc; }
.ppm-wrap { padding: .6rem .2rem; }
.inp-label { font-size: .85rem; font-weight: 600; color: #445; margin-bottom: .3rem; display: block; }
#ppm-input {
width: 100%; padding: .5rem .7rem; font-size: 1rem; border: 1.5px solid #b0bec5;
border-radius: 8px; outline: none; background: #fff;
}
#ppm-input:focus { border-color: #4a7fc1; }
.presets { display: flex; flex-wrap: wrap; gap: .35rem; margin: .5rem 0 .6rem; align-items: center; }
.preset-label { font-size: .8rem; color: #667; }
.preset {
font-size: .78rem; padding: .25rem .55rem; border: 1px solid #aab8cc; background: #fff;
border-radius: 20px; cursor: pointer; color: #2a4a7a; transition: background .1s;
}
.preset:hover { background: #dce8f5; }
.summary {
display: flex; gap: 1.2rem; font-size: .9rem; font-weight: 600; color: #2a4a7a;
background: #dce8f5; border-radius: 8px; padding: .4rem .7rem; margin-bottom: .4rem;
}
.log-head, .log-row {
display: grid;
grid-template-columns: 2.2rem 5.5rem 3.2rem 4.2rem 3.5rem 1fr;
gap: 0 .3rem; align-items: center;
font-size: .78rem; padding: .18rem .3rem;
}
.log-head {
font-weight: 700; color: #445; border-bottom: 1px solid #c0ccd8;
background: #eef2f7; border-radius: 6px 6px 0 0;
}
.log-row { border-bottom: 1px solid #e4eaf1; }
.log-row:last-child { border-bottom: none; }
.log-row:hover { background: #f0f5fa; }
.col-char { font-weight: 700; font-family: ui-monospace, monospace; color: #1a3a6a; }
.col-ctx { font-family: ui-monospace, monospace; color: #3a5a8a; font-size: .75rem; }
.col-depth { text-align: center; }
.col-prob { text-align: right; color: #2a6a3a; }
.col-bits { text-align: right; color: #7a3a1a; font-weight: 600; }
.bar-wrap { height: 7px; background: #dce8f5; border-radius: 4px; overflow: hidden; }
.bar-fill { height: 100%; background: linear-gradient(90deg,#4a7fc1,#2ecc71); border-radius: 4px; }
.badge {
display: inline-block; font-size: .65rem; padding: .1rem .3rem;
border-radius: 4px; font-weight: 700; margin-right: .1rem;
}
.badge-d0 { background: #f3e8ff; color: #6a0dad; }
.badge-d1 { background: #e0f0ff; color: #0a4fa0; }
.badge-d2 { background: #e0fff0; color: #0a6a30; }
.badge-d3 { background: #fff5e0; color: #8a5000; }
.badge-d4p { background: #ffe0e0; color: #8a0000; }
#log { max-height: 260px; overflow-y: auto; border: 1px solid #c0ccd8; border-radius: 0 0 8px 8px; background: #fff; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// PPM (Prediction by Partial Matching) — simplified PPM-A variant
// We maintain a context trie of order up to MAX_ORDER.
// For each new symbol, we find the longest suffix context that has seen at least one symbol, read the count of the new symbol in that context, compute a probability using Method A escape, and fall back to shorter orders until found.
// Bits = -log2(probability). Total bits approximates PPM compressed size.
const MAX_ORDER = 4;
const ESC_DENOM_EXTRA = 1; // Method A: escape count = 1, total = count_sum + 1
// Trie node: Map<char, {count, children: Map<char, node>}>
// We represent the model as a map from context-string to frequency tables.
// context '' = order-0, 'a' = order-1 with context 'a', etc.
const model = new Map(); // context string -> Map<char, count>
function getCtx(s) {
if (!model.has(s)) model.set(s, new Map());
return model.get(s);
}
function totalCount(freq) {
let t = 0; freq.forEach(v => t += v); return t;
}
// Encode symbol c with current context suffix of length up to MAX_ORDER.
// Returns { prob, bitsStr, depth, ctxUsed }
function encode(c, history) {
// Try contexts from longest to shortest, then order-(-1) = uniform
let probProduct = 1.0; // accumulated escape probabilities
for (let order = Math.min(MAX_ORDER, history.length); order >= 0; order--) {
const ctx = history.slice(history.length - order);
const freq = getCtx(ctx);
const total = totalCount(freq);
const count = freq.get(c) || 0;
if (order === 0 && count === 0) {
// Order-(-1) fallback: uniform over 95 printable ASCII
const p = probProduct / 95;
return { prob: p, bits: -Math.log2(p), depth: -1, ctxUsed: '(uniform)' };
}
if (count > 0) {
// Method A: p(c|ctx) = count / (total + 1)
const escDenom = total + ESC_DENOM_EXTRA;
const p = probProduct * (count / escDenom);
return { prob: p, bits: -Math.log2(p), depth: order, ctxUsed: JSON.stringify(ctx) };
}
// escape: p_esc = 1 / (total + 1)
if (total > 0) {
const escDenom = total + ESC_DENOM_EXTRA;
probProduct *= ESC_DENOM_EXTRA / escDenom;
}
}
const p = probProduct / 95;
return { prob: p, bits: -Math.log2(p), depth: -1, ctxUsed: '(uniform)' };
}
// Update model: add c to all suffix contexts up to MAX_ORDER
function update(c, history) {
for (let order = 0; order <= Math.min(MAX_ORDER, history.length); order++) {
const ctx = history.slice(history.length - order);
const freq = getCtx(ctx);
freq.set(c, (freq.get(c) || 0) + 1);
}
}
// Badge HTML for depth
function depthBadge(d) {
if (d < 0) return '<span class="badge badge-d0">∅</span>';
if (d === 0) return '<span class="badge badge-d0">0</span>';
if (d === 1) return '<span class="badge badge-d1">1</span>';
if (d === 2) return '<span class="badge badge-d2">2</span>';
if (d === 3) return '<span class="badge badge-d3">3</span>';
return '<span class="badge badge-d4p">' + d + '</span>';
}
let lastText = '';
function process(text) {
// Reset model for fresh run
model.clear();
const log = document.getElementById('log');
const logHead = document.getElementById('log-head');
const summary = document.getElementById('summary');
log.innerHTML = '';
if (!text) { logHead.style.display = 'none'; summary.style.display = 'none'; return; }
let totalBits = 0;
let rows = '';
let history = '';
for (let i = 0; i < text.length; i++) {
const c = text[i];
const res = encode(c, history);
update(c, history);
history += c;
totalBits += res.bits;
// Saving bar: max 8 bits baseline (uniform 256), show how much better we did
const baseline = 8;
const saving = Math.max(0, Math.min(1, (baseline - res.bits) / baseline));
const displayChar = c === ' ' ? '·' : c;
const displayCtx = res.depth <= 0 ? '—' :
(JSON.parse(res.ctxUsed) || '').replace(/ /g, '·');
rows += `<div class="log-row">
<span class="col-char">${displayChar}</span>
<span class="col-ctx">${displayCtx || '""'}</span>
<span class="col-depth">${depthBadge(res.depth)}</span>
<span class="col-prob">${(res.prob * 100).toFixed(1)}%</span>
<span class="col-bits">${res.bits.toFixed(2)}</span>
<span class="col-bar"><div class="bar-wrap"><div class="bar-fill" style="width:${(saving*100).toFixed(1)}%"></div></div></span>
</div>`;
}
log.innerHTML = rows;
logHead.style.display = '';
summary.style.display = '';
document.getElementById('total-bits').textContent =
fmt('Total: %totalBits% bits', { totalBits: totalBits.toFixed(1) });
document.getElementById('bpc').textContent =
fmt('Avg: %v% bits/char', { v: (totalBits / text.length).toFixed(2) });
// Scroll to bottom to show latest
log.scrollTop = log.scrollHeight;
}
const input = document.getElementById('ppm-input');
input.addEventListener('input', () => {
lastText = input.value;
process(lastText);
});
document.querySelectorAll('.preset').forEach(btn => {
btn.addEventListener('click', () => {
input.value = btn.dataset.text;
lastText = input.value;
process(lastText);
});
});
// Kick off with a starter
input.value = 'abracadabra';
process(input.value);
<div class="ppm-wrap">
<div class="input-row">
<label for="ppm-input" class="inp-label">Type text here:</label>
<input id="ppm-input" type="text" autocomplete="off" spellcheck="false"
placeholder='e.g. "abracadabra" or "the cat sat on the mat"' />
</div>
<div class="presets">
<span class="preset-label">Presets:</span>
<button class="preset" data-text="abracadabra">abracadabra</button>
<button class="preset" data-text="the cat sat on the mat">the cat sat on the mat</button>
<button class="preset" data-text="aaabbbaaabbb">aaabbbaaabbb</button>
<button class="preset" data-text="hello world hello world hello">hello world ×2</button>
</div>
<div class="summary" id="summary" style="display:none">
<span id="total-bits"></span>
<span id="bpc"></span>
</div>
<div class="log-head" id="log-head" style="display:none">
<span class="col-char">Char</span>
<span class="col-ctx">Context used</span>
<span class="col-depth">Order</span>
<span class="col-prob">Prob</span>
<span class="col-bits">Bits</span>
<span class="col-bar">Compression saving</span>
</div>
<div id="log"></div>
</div>
* { box-sizing: border-box; }
body { font-family: system-ui, sans-serif; margin: 0; color: #1a2030; background: #f7f9fc; }
.ppm-wrap { padding: .6rem .2rem; }
.inp-label { font-size: .85rem; font-weight: 600; color: #445; margin-bottom: .3rem; display: block; }
#ppm-input {
width: 100%; padding: .5rem .7rem; font-size: 1rem; border: 1.5px solid #b0bec5;
border-radius: 8px; outline: none; background: #fff;
}
#ppm-input:focus { border-color: #4a7fc1; }
.presets { display: flex; flex-wrap: wrap; gap: .35rem; margin: .5rem 0 .6rem; align-items: center; }
.preset-label { font-size: .8rem; color: #667; }
.preset {
font-size: .78rem; padding: .25rem .55rem; border: 1px solid #aab8cc; background: #fff;
border-radius: 20px; cursor: pointer; color: #2a4a7a; transition: background .1s;
}
.preset:hover { background: #dce8f5; }
.summary {
display: flex; gap: 1.2rem; font-size: .9rem; font-weight: 600; color: #2a4a7a;
background: #dce8f5; border-radius: 8px; padding: .4rem .7rem; margin-bottom: .4rem;
}
.log-head, .log-row {
display: grid;
grid-template-columns: 2.2rem 5.5rem 3.2rem 4.2rem 3.5rem 1fr;
gap: 0 .3rem; align-items: center;
font-size: .78rem; padding: .18rem .3rem;
}
.log-head {
font-weight: 700; color: #445; border-bottom: 1px solid #c0ccd8;
background: #eef2f7; border-radius: 6px 6px 0 0;
}
.log-row { border-bottom: 1px solid #e4eaf1; }
.log-row:last-child { border-bottom: none; }
.log-row:hover { background: #f0f5fa; }
.col-char { font-weight: 700; font-family: ui-monospace, monospace; color: #1a3a6a; }
.col-ctx { font-family: ui-monospace, monospace; color: #3a5a8a; font-size: .75rem; }
.col-depth { text-align: center; }
.col-prob { text-align: right; color: #2a6a3a; }
.col-bits { text-align: right; color: #7a3a1a; font-weight: 600; }
.bar-wrap { height: 7px; background: #dce8f5; border-radius: 4px; overflow: hidden; }
.bar-fill { height: 100%; background: linear-gradient(90deg,#4a7fc1,#2ecc71); border-radius: 4px; }
.badge {
display: inline-block; font-size: .65rem; padding: .1rem .3rem;
border-radius: 4px; font-weight: 700; margin-right: .1rem;
}
.badge-d0 { background: #f3e8ff; color: #6a0dad; }
.badge-d1 { background: #e0f0ff; color: #0a4fa0; }
.badge-d2 { background: #e0fff0; color: #0a6a30; }
.badge-d3 { background: #fff5e0; color: #8a5000; }
.badge-d4p { background: #ffe0e0; color: #8a0000; }
#log { max-height: 260px; overflow-y: auto; border: 1px solid #c0ccd8; border-radius: 0 0 8px 8px; background: #fff; }
const fmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// PPM (Prediction by Partial Matching) — simplified PPM-A variant
// We maintain a context trie of order up to MAX_ORDER.
// For each new symbol, we find the longest suffix context that has seen at least one symbol, read the count of the new symbol in that context, compute a probability using Method A escape, and fall back to shorter orders until found.
// Bits = -log2(probability). Total bits approximates PPM compressed size.
const MAX_ORDER = 4;
const ESC_DENOM_EXTRA = 1; // Method A: escape count = 1, total = count_sum + 1
// Trie node: Map<char, {count, children: Map<char, node>}>
// We represent the model as a map from context-string to frequency tables.
// context '' = order-0, 'a' = order-1 with context 'a', etc.
const model = new Map(); // context string -> Map<char, count>
function getCtx(s) {
if (!model.has(s)) model.set(s, new Map());
return model.get(s);
}
function totalCount(freq) {
let t = 0; freq.forEach(v => t += v); return t;
}
// Encode symbol c with current context suffix of length up to MAX_ORDER.
// Returns { prob, bitsStr, depth, ctxUsed }
function encode(c, history) {
// Try contexts from longest to shortest, then order-(-1) = uniform
let probProduct = 1.0; // accumulated escape probabilities
for (let order = Math.min(MAX_ORDER, history.length); order >= 0; order--) {
const ctx = history.slice(history.length - order);
const freq = getCtx(ctx);
const total = totalCount(freq);
const count = freq.get(c) || 0;
if (order === 0 && count === 0) {
// Order-(-1) fallback: uniform over 95 printable ASCII
const p = probProduct / 95;
return { prob: p, bits: -Math.log2(p), depth: -1, ctxUsed: '(uniform)' };
}
if (count > 0) {
// Method A: p(c|ctx) = count / (total + 1)
const escDenom = total + ESC_DENOM_EXTRA;
const p = probProduct * (count / escDenom);
return { prob: p, bits: -Math.log2(p), depth: order, ctxUsed: JSON.stringify(ctx) };
}
// escape: p_esc = 1 / (total + 1)
if (total > 0) {
const escDenom = total + ESC_DENOM_EXTRA;
probProduct *= ESC_DENOM_EXTRA / escDenom;
}
}
const p = probProduct / 95;
return { prob: p, bits: -Math.log2(p), depth: -1, ctxUsed: '(uniform)' };
}
// Update model: add c to all suffix contexts up to MAX_ORDER
function update(c, history) {
for (let order = 0; order <= Math.min(MAX_ORDER, history.length); order++) {
const ctx = history.slice(history.length - order);
const freq = getCtx(ctx);
freq.set(c, (freq.get(c) || 0) + 1);
}
}
// Badge HTML for depth
function depthBadge(d) {
if (d < 0) return '<span class="badge badge-d0">∅</span>';
if (d === 0) return '<span class="badge badge-d0">0</span>';
if (d === 1) return '<span class="badge badge-d1">1</span>';
if (d === 2) return '<span class="badge badge-d2">2</span>';
if (d === 3) return '<span class="badge badge-d3">3</span>';
return '<span class="badge badge-d4p">' + d + '</span>';
}
let lastText = '';
function process(text) {
// Reset model for fresh run
model.clear();
const log = document.getElementById('log');
const logHead = document.getElementById('log-head');
const summary = document.getElementById('summary');
log.innerHTML = '';
if (!text) { logHead.style.display = 'none'; summary.style.display = 'none'; return; }
let totalBits = 0;
let rows = '';
let history = '';
for (let i = 0; i < text.length; i++) {
const c = text[i];
const res = encode(c, history);
update(c, history);
history += c;
totalBits += res.bits;
// Saving bar: max 8 bits baseline (uniform 256), show how much better we did
const baseline = 8;
const saving = Math.max(0, Math.min(1, (baseline - res.bits) / baseline));
const displayChar = c === ' ' ? '·' : c;
const displayCtx = res.depth <= 0 ? '—' :
(JSON.parse(res.ctxUsed) || '').replace(/ /g, '·');
rows += `<div class="log-row">
<span class="col-char">${displayChar}</span>
<span class="col-ctx">${displayCtx || '""'}</span>
<span class="col-depth">${depthBadge(res.depth)}</span>
<span class="col-prob">${(res.prob * 100).toFixed(1)}%</span>
<span class="col-bits">${res.bits.toFixed(2)}</span>
<span class="col-bar"><div class="bar-wrap"><div class="bar-fill" style="width:${(saving*100).toFixed(1)}%"></div></div></span>
</div>`;
}
log.innerHTML = rows;
logHead.style.display = '';
summary.style.display = '';
document.getElementById('total-bits').textContent =
fmt('Total: %totalBits% bits', { totalBits: totalBits.toFixed(1) });
document.getElementById('bpc').textContent =
fmt('Avg: %v% bits/char', { v: (totalBits / text.length).toFixed(2) });
// Scroll to bottom to show latest
log.scrollTop = log.scrollHeight;
}
const input = document.getElementById('ppm-input');
input.addEventListener('input', () => {
lastText = input.value;
process(lastText);
});
document.querySelectorAll('.preset').forEach(btn => {
btn.addEventListener('click', () => {
input.value = btn.dataset.text;
lastText = input.value;
process(lastText);
});
});
// Kick off with a starter
input.value = 'abracadabra';
process(input.value);
Notice how the bits per character drop as PPM accumulates more context. The first few characters cost almost 8 bits each (the model knows nothing); after a dozen characters, common continuations cost 2–4 bits. That gap is the compression gain — and it grows the more the text follows patterns PPM has seen before.
Comments
Loading comments...