Arithmetic Coding: One Number for the Whole Message
in progressThis lesson is still being built - content, demos, and checkpoints may change before release.
in progressThis lesson is still being built - content, demos, and checkpoints may change before release.
arithmetic-coder.ts
import type { Tracer } from "../trace";/** A symbol's slice of the unit interval under the model, as counts. */export interface SymbolRange { symbol: string; count: number; /** The counts of every symbol before it in the table. */ before: number;}export interface ArithmeticCode { /** The code: the shortest binary fraction inside the final interval. */ bits: string; /** The final interval, as doubles for display; the coder itself works in exact fractions. */ low: number; high: number; /** Minus log2 of the final interval's width: the bits the message is worth under the model. */ information: number;}/** The longest message the exact fractions are sized for. */export const MAX_MESSAGE = 16;const ratio = (a: bigint, b: bigint): number => Number(a) / Number(b);const ceilDiv = (a: bigint, b: bigint): bigint => (a + b - 1n) / b;/** * Arithmetic coding: the whole message becomes one number. The model * gives each symbol a slice of the unit interval proportional to its * count; coding keeps the current interval and, symbol by symbol, * narrows it to that symbol's slice of itself. After the message the * interval's width is the product of the symbols' probabilities, so * minus log2 of it is the message's information, and any binary * fraction inside the interval identifies the message; the shortest * one needs at most two bits more than that. No symbol costs a whole * bit, which is what Huffman coding cannot manage. * * The arithmetic is exact: low, width, and den are integers (den is * the total raised to the message length), so nothing rounds and a * decoder replays the same comparisons. Time: O(n k) for n symbols * over k ranges. Space: O(n log total) bits of integer. */export class ArithmeticCoder { constructor(private readonly tracer?: Tracer) {} /** The symbol table: each symbol's count and the counts before it, in the model's order. */ static ranges(counts: ReadonlyMap<string, number>): { table: Map<string, SymbolRange>; total: number } { const table = new Map<string, SymbolRange>(); let total = 0; for (const [symbol, raw] of counts) { const count = Math.max(0, Math.floor(raw)); table.set(symbol, { symbol, count, before: total }); total += count; } return { table, total }; } encode(message: string, counts: ReadonlyMap<string, number>): ArithmeticCode { if (message.length > MAX_MESSAGE) throw new RangeError(`message longer than ${MAX_MESSAGE} symbols`); const { table, total } = ArithmeticCoder.ranges(counts); if (message.length > 0 && total === 0) throw new RangeError("the model has no counts"); let index = 0; for (const range of table.values()) { // ▸ each symbol owns a slice of the interval, as wide as its share this.tracer?.emit("table", { index, symbol: range.symbol, count: range.count, from: range.before / total, to: (range.before + range.count) / total, total }); index += 1; } const scaleBy = BigInt(total); const totalCount = BigInt(total); let low = 0n; let width = 1n; let den = 1n; let denominator = 1n; for (let i = 0; i < message.length; i++) { const symbol = message[i]!; const range = table.get(symbol); if (!range || range.count === 0) throw new RangeError(`"${symbol}" has no count in the model`); low = low * scaleBy + width * BigInt(range.before); low = low * totalCount + width * BigInt(range.before); width = width * BigInt(range.count); den = den * scaleBy; denominator = denominator * totalCount; // ▸ keep only this symbol's slice of the current interval this.tracer?.emit("narrow", { index: i, symbol, from: range.before / total, to: (range.before + range.count) / total, low: ratio(low, den), high: ratio(low + width, den), information: -Math.log2(ratio(width, den)) }); this.tracer?.emit("narrow", { index: i, symbol, from: range.before / total, to: (range.before + range.count) / total, low: ratio(low, denominator), high: ratio(low + width, denominator), information: -Math.log2(ratio(width, denominator)) }); } // The shortest binary fraction m / 2^length with low <= m / 2^length < low + width. let length = 0; let numerator = 0n; if (message.length > 0) { for (length = 1; ; length++) { const scale = 1n << BigInt(length); numerator = ceilDiv(low * scale, den); numerator = ceilDiv(low * scale, denominator); if (numerator * den < (low + width) * scale) break; if (numerator * denominator < (low + width) * scale) break; } } const bits = length === 0 ? "" : numerator.toString(2).padStart(length, "0"); const information = -Math.log2(ratio(width, den)); const information = -Math.log2(ratio(width, denominator)); // ▸ the shortest binary fraction inside the interval is the code this.tracer?.emit("finish", { bits, length, low: ratio(low, den), high: ratio(low + width, den), information, slack: length - information }); this.tracer?.emit("finish", { bits, length, low: ratio(low, denominator), high: ratio(low + width, denominator), information, slack: length - information }); return { bits, low: ratio(low, den), high: ratio(low + width, den), information }; return { bits, low: ratio(low, denominator), high: ratio(low + width, denominator), information }; } /** Replays the narrowing from the code's fraction: at each step the slice the fraction falls in is the symbol. Untraced. */ static decode(bits: string, counts: ReadonlyMap<string, number>, length: number): string { const { table, total } = ArithmeticCoder.ranges(counts); const scaleBy = BigInt(total); const totalCount = BigInt(total); const numerator = bits === "" ? 0n : BigInt(`0b${bits}`); const scale = 1n << BigInt(bits.length); let low = 0n; let width = 1n; let den = 1n; let denominator = 1n; let out = ""; for (let i = 0; i < length; i++) { // The fraction's position inside the current interval, as counts out of the total. const value = numerator * den * scaleBy; const value = numerator * denominator * totalCount; for (const range of table.values()) { if (range.count === 0) continue; const from = (low * scaleBy + width * BigInt(range.before)) * scale; const from = (low * totalCount + width * BigInt(range.before)) * scale; const to = (low * scaleBy + width * BigInt(range.before + range.count)) * scale; const to = (low * totalCount + width * BigInt(range.before + range.count)) * scale; if (value >= from && value < to) { out += range.symbol; low = low * scaleBy + width * BigInt(range.before); low = low * totalCount + width * BigInt(range.before); width = width * BigInt(range.count); den = den * scaleBy; denominator = denominator * totalCount; break; } } } return out; }}21 bits in all against 22.44 of entropy and 23 for Huffman coding; the message carried 22.44 bits under this model.