Add per-group entropy limit bars to the compression chart
5 changed files+201−39
src/App.tsxmodified+11−4View file
@@ -6,7 +6,7 @@ import CompressionChart from './components/CompressionChart'
66 import MathSection from './components/MathSection'
77 import { DEFAULT_SPEC, clampSpec, designKernel, kernelNorm } from './model/filters'
88 import { theoreticalRateBits } from './model/theory'
9-import type { CodecResult } from './compress/codecs'
9+import type { BoundResult, CodecResult } from './compress/codecs'
1010 import type { CompressRequest, CompressResponse } from './worker/compressWorker'
1111
1212 const BLOCK_SIZE = 120000
@@ -14,6 +14,7 @@ const BLOCK_SEED = 20260729
1414
1515 interface CompressionState {
1616 results: CodecResult[]
17+ bounds: BoundResult[]
1718 empiricalStd: number
1819 computing: boolean
1920 error: string | null
@@ -23,6 +24,7 @@ interface CompressionState {
2324 function useCompression(kernel: Float64Array, sigma: number, dither: boolean): CompressionState {
2425 const [state, setState] = useState<CompressionState>({
2526 results: [],
27+ bounds: [],
2628 empiricalStd: 0,
2729 computing: true,
2830 error: null,
@@ -38,6 +40,7 @@ function useCompression(kernel: Float64Array, sigma: number, dither: boolean): C
3840 if (e.data.id !== idRef.current) return
3941 setState({
4042 results: e.data.error ? [] : e.data.results,
43+ bounds: e.data.error ? [] : e.data.bounds,
4144 empiricalStd: e.data.empiricalStd,
4245 computing: false,
4346 error: e.data.error ?? null,
@@ -147,6 +150,7 @@ export default function App() {
147150 ) : (
148151 <CompressionChart
149152 results={compression.results}
153+ bounds={compression.bounds}
150154 theoryBits={theoryBits}
151155 computing={compression.computing}
152156 />
@@ -154,9 +158,12 @@ export default function App() {
154158 <p className="card-note">
155159 Measured on a {BLOCK_SIZE.toLocaleString()}-sample block of the same latent data the
156160 signal view shows; sizes include everything a decoder needs (ANS symbol table, LPC
157- coefficients). Baseline is raw int16 (16 bits/sample). The dashed line is the
158- theoretical rate R from the spectral formula in the math section — approximate where
159- quantization dominates the spectrum (see the S(f) = 1 threshold on the response plot).
161+ coefficients). Baseline is raw int16 (16 bits/sample). The hollow bar in each group is
162+ that group's entropy limit — the order-0 entropy of the stream being coded, which no
163+ per-sample entropy coder can beat and ANS falls short of by its symbol table plus its
164+ own arithmetic loss. The dashed line is the theoretical rate R from the spectral
165+ formula in the math section — approximate where quantization dominates the spectrum
166+ (see the S(f) = 1 threshold on the response plot).
160167 </p>
161168 </section>
162169
src/app.cssmodified+13−0View file
@@ -351,6 +351,12 @@ body {
351351 vertical-align: -1px;
352352 }
353353
354+/* Hollow: the mark for a limit, matching the outlined bars. */
355+.legend .swatch.hollow {
356+ background: color-mix(in srgb, var(--muted) 12%, transparent);
357+ border: 1.25px solid var(--muted);
358+}
359+
354360 .chart-body {
355361 position: relative;
356362 transition: opacity 0.15s;
@@ -386,6 +392,13 @@ body {
386392 font-variant-numeric: tabular-nums;
387393 }
388394
395+/* Limit rows read one step quieter than the measured ones. */
396+.bar-row-label.bound,
397+.bar-value.bound {
398+ fill: var(--muted);
399+ font-style: italic;
400+}
401+
389402 .axis-tick {
390403 fill: var(--muted);
391404 font-size: 11px;
src/components/CompressionChart.tsxmodified+119−33View file
@@ -1,24 +1,39 @@
11 import { useRef, useState } from 'react'
2-import type { CodecResult } from '../compress/codecs'
2+import type { BoundResult, CodecResult } from '../compress/codecs'
33 import { useWidth } from './useWidth'
44
5-const GROUPS = ['no prefilter', 'delta', `LPC`]
5+const GROUPS = ['no prefilter', 'delta', 'LPC']
66 const CODER_NAMES = ['zlib', 'zstd', 'ANS']
77 const CODER_VARS = ['var(--series-1)', 'var(--series-2)', 'var(--series-3)']
8+const BOUND_LABEL = 'entropy limit'
89
9-const LABEL_W = 96
10+const LABEL_W = 100
1011 const RIGHT_PAD = 64
1112 const AXIS_H = 26
1213 const GROUP_H = 20
1314 const ROW_H = 24
1415 const BAR_H = 16
16+const ROWS_PER_GROUP = 4
1517
1618 type Metric = 'bits' | 'ratio'
1719
20+/** One drawn bar: a measured codec size, or the entropy limit for its group. */
21+interface Row {
22+ key: string
23+ label: string
24+ name: string
25+ note: string
26+ bytes: number
27+ bitsPerSample: number
28+ ratio: number
29+ isBound: boolean
30+ color: string
31+}
32+
1833 interface Tip {
1934 x: number
2035 y: number
21- result: CodecResult
36+ row: Row
2237 }
2338
2439 function axisTicks(max: number): number[] {
@@ -34,8 +49,46 @@ function barPath(x0: number, y: number, len: number, h: number): string {
3449 return `M${x0},${y} h${len - r} a${r},${r} 0 0 1 ${r},${r} v${h - 2 * r} a${r},${r} 0 0 1 ${-r},${r} h${-(len - r)} z`
3550 }
3651
52+/** Codec rows then the entropy limit, group by group. */
53+function buildRows(results: CodecResult[], bounds: BoundResult[]): Row[] {
54+ const rows: Row[] = []
55+ GROUPS.forEach((group, g) => {
56+ CODER_NAMES.forEach((coder, c) => {
57+ const r = results[g * 3 + c]
58+ if (!r) return
59+ rows.push({
60+ key: r.codec,
61+ label: coder,
62+ name: r.codec,
63+ note: r.note,
64+ bytes: r.bytes,
65+ bitsPerSample: r.bitsPerSample,
66+ ratio: r.ratio,
67+ isBound: false,
68+ color: CODER_VARS[c],
69+ })
70+ })
71+ const b = bounds.find(x => x.group === group)
72+ if (b) {
73+ rows.push({
74+ key: `${group}-bound`,
75+ label: BOUND_LABEL,
76+ name: `${BOUND_LABEL} (${group})`,
77+ note: b.note,
78+ bytes: b.bytes,
79+ bitsPerSample: b.bitsPerSample,
80+ ratio: b.ratio,
81+ isBound: true,
82+ color: 'var(--muted)',
83+ })
84+ }
85+ })
86+ return rows
87+}
88+
3789 export default function CompressionChart(props: {
3890 results: CodecResult[]
91+ bounds: BoundResult[]
3992 theoryBits: number
4093 computing: boolean
4194 }) {
@@ -43,37 +96,42 @@ export default function CompressionChart(props: {
4396 const width = useWidth(ref, 720)
4497 const [metric, setMetric] = useState<Metric>('ratio')
4598 const [tip, setTip] = useState<Tip | null>(null)
46- const [hovered, setHovered] = useState<number | null>(null)
99+ const [hovered, setHovered] = useState<string | null>(null)
47100
48- const { results, theoryBits } = props
101+ const { results, bounds, theoryBits } = props
49102 if (results.length === 0) {
50103 return <p className="card-note">Computing compression on the first block…</p>
51104 }
52105
53- const value = (r: CodecResult) => (metric === 'bits' ? r.bitsPerSample : r.ratio)
106+ const rows = buildRows(results, bounds)
107+ const value = (r: { bitsPerSample: number; ratio: number }) =>
108+ metric === 'bits' ? r.bitsPerSample : r.ratio
54109 const theoryValue = metric === 'bits' ? theoryBits : theoryBits > 0 ? 16 / theoryBits : NaN
55110 const theoryVisible = Number.isFinite(theoryValue) && theoryValue > 0
56111 const xMax =
57112 metric === 'bits'
58- ? Math.max(16, ...results.map(value), theoryVisible ? theoryValue : 0) * 1.02
59- : Math.max(...results.map(value), theoryVisible ? theoryValue : 0) * 1.1
113+ ? Math.max(16, ...rows.map(value), theoryVisible ? theoryValue : 0) * 1.02
114+ : Math.max(...rows.map(value), theoryVisible ? theoryValue : 0) * 1.1
60115
61116 const plotW = width - LABEL_W - RIGHT_PAD
62- const height = AXIS_H + GROUPS.length * (GROUP_H + 3 * ROW_H) + 6
117+ const height = AXIS_H + GROUPS.length * (GROUP_H + ROWS_PER_GROUP * ROW_H) + 6
63118 const xOf = (v: number) => LABEL_W + (v / xMax) * plotW
64- const rowY = (i: number) => AXIS_H + Math.floor(i / 3) * (GROUP_H + 3 * ROW_H) + GROUP_H + (i % 3) * ROW_H
119+ const rowY = (i: number) =>
120+ AXIS_H +
121+ Math.floor(i / ROWS_PER_GROUP) * (GROUP_H + ROWS_PER_GROUP * ROW_H) +
122+ GROUP_H +
123+ (i % ROWS_PER_GROUP) * ROW_H
65124
66- const fmt = (r: CodecResult) =>
67- metric === 'bits' ? r.bitsPerSample.toFixed(2) : `${r.ratio.toFixed(2)}×`
125+ const fmt = (r: Row) => (metric === 'bits' ? r.bitsPerSample.toFixed(2) : `${r.ratio.toFixed(2)}×`)
68126
69- const onBarMove = (e: React.PointerEvent, r: CodecResult) => {
127+ const onBarMove = (e: React.PointerEvent, row: Row) => {
70128 const box = ref.current!.getBoundingClientRect()
71- setTip({ x: e.clientX - box.left, y: e.clientY - box.top, result: r })
129+ setTip({ x: e.clientX - box.left, y: e.clientY - box.top, row })
72130 }
73131
74132 const theoryX = theoryVisible ? xOf(theoryValue) : 0
75133 const theoryLabel =
76- metric === 'bits' ? `entropy rate R = ${theoryBits.toFixed(2)}` : `R ⇒ ${(16 / theoryBits).toFixed(2)}×`
134+ metric === 'bits' ? `R = ${theoryBits.toFixed(2)}` : `R ⇒ ${(16 / theoryBits).toFixed(2)}×`
77135
78136 return (
79137 <div>
@@ -98,6 +156,10 @@ export default function CompressionChart(props: {
98156 {name}
99157 </span>
100158 ))}
159+ <span>
160+ <span className="swatch hollow" />
161+ entropy limit (not achieved)
162+ </span>
101163 </div>
102164 </div>
103165 <div className={`chart-body${props.computing ? ' computing' : ''}`} ref={ref}>
@@ -113,22 +175,45 @@ export default function CompressionChart(props: {
113175 ))}
114176 <line x1={xOf(0)} x2={xOf(0)} y1={AXIS_H - 6} y2={height - 4} stroke="var(--baseline)" strokeWidth={1} />
115177 {GROUPS.map((g, gi) => (
116- <text key={g} x={0} y={AXIS_H + gi * (GROUP_H + 3 * ROW_H) + 15} className="bar-group-label">
178+ <text
179+ key={g}
180+ x={0}
181+ y={AXIS_H + gi * (GROUP_H + ROWS_PER_GROUP * ROW_H) + 15}
182+ className="bar-group-label"
183+ >
117184 {g}
118185 </text>
119186 ))}
120- {results.map((r, i) => {
187+ {rows.map((r, i) => {
121188 const y = rowY(i)
122189 const len = Math.max(1, (value(r) / xMax) * plotW)
123- const label = fmt(r)
124190 return (
125- <g key={r.codec} opacity={hovered === null || hovered === i ? 1 : 0.45}>
126- <text x={8} y={y + BAR_H / 2 + 4} className="bar-row-label">
127- {CODER_NAMES[i % 3]}
191+ <g key={r.key} opacity={hovered === null || hovered === r.key ? 1 : 0.45}>
192+ <text
193+ x={8}
194+ y={y + BAR_H / 2 + 4}
195+ className={r.isBound ? 'bar-row-label bound' : 'bar-row-label'}
196+ >
197+ {r.label}
128198 </text>
129- <path d={barPath(xOf(0), y, len, BAR_H)} fill={CODER_VARS[i % 3]} />
130- <text x={xOf(0) + len + 6} y={y + BAR_H / 2 + 4} className="bar-value">
131- {label}
199+ {r.isBound ? (
200+ // Hollow: a limit nobody reached, not a measured size.
201+ <path
202+ d={barPath(xOf(0), y + 1, len, BAR_H - 2)}
203+ fill="var(--muted)"
204+ fillOpacity={0.12}
205+ stroke="var(--muted)"
206+ strokeWidth={1.25}
207+ />
208+ ) : (
209+ <path d={barPath(xOf(0), y, len, BAR_H)} fill={r.color} />
210+ )}
211+ <text
212+ x={xOf(0) + len + 6}
213+ y={y + BAR_H / 2 + 4}
214+ className={r.isBound ? 'bar-value bound' : 'bar-value'}
215+ >
216+ {fmt(r)}
132217 </text>
133218 <rect
134219 x={0}
@@ -137,7 +222,7 @@ export default function CompressionChart(props: {
137222 height={ROW_H}
138223 fill="transparent"
139224 onPointerMove={e => {
140- setHovered(i)
225+ setHovered(r.key)
141226 onBarMove(e, r)
142227 }}
143228 onPointerLeave={() => {
@@ -175,12 +260,13 @@ export default function CompressionChart(props: {
175260 <div className="viz-tooltip" style={{ left: tip.x + 14, top: tip.y - 8 }}>
176261 <div>
177262 <span className="tip-value">
178- {tip.result.bitsPerSample.toFixed(3)} bits/sample · {tip.result.ratio.toFixed(2)}×
263+ {tip.row.bitsPerSample.toFixed(3)} bits/sample · {tip.row.ratio.toFixed(2)}×
179264 </span>{' '}
180- <span className="tip-label">{tip.result.codec}</span>
265+ <span className="tip-label">{tip.row.name}</span>
181266 </div>
182267 <div className="tip-label">
183- {tip.result.bytes.toLocaleString()} bytes · {tip.result.note}
268+ {tip.row.isBound ? 'equivalent to ' : ''}
269+ {tip.row.bytes.toLocaleString()} bytes · {tip.row.note}
184270 </div>
185271 </div>
186272 )}
@@ -197,9 +283,9 @@ export default function CompressionChart(props: {
197283 </tr>
198284 </thead>
199285 <tbody>
200- {results.map(r => (
201- <tr key={r.codec}>
202- <td>{r.codec}</td>
286+ {rows.map(r => (
287+ <tr key={r.key}>
288+ <td>{r.name}</td>
203289 <td>{r.bytes.toLocaleString()}</td>
204290 <td>{r.bitsPerSample.toFixed(3)}</td>
205291 <td>{r.ratio.toFixed(3)}</td>
@@ -207,7 +293,7 @@ export default function CompressionChart(props: {
207293 ))}
208294 {theoryBits > 0 && (
209295 <tr>
210- <td>theory: entropy rate R</td>
296+ <td>theory: rate R</td>
211297 <td>—</td>
212298 <td>{theoryBits.toFixed(3)}</td>
213299 <td>{(16 / theoryBits).toFixed(3)}</td>
src/compress/codecs.tsmodified+47−0View file
@@ -156,6 +156,53 @@ export const LPC_ANS: Codec = {
156156 /** The general-purpose compressors, which know nothing about the data. */
157157 export const GENERAL_CODECS: Codec[] = [ZLIB, ZSTD]
158158
159+/**
160+ * Order-0 (memoryless) entropy of an int16 stream, in bits per sample: what a
161+ * perfect entropy coder for the sample histogram would spend, with nothing
162+ * charged for describing that histogram. The ANS bars sit above this by the
163+ * symbol table plus the coder's own arithmetic loss.
164+ */
165+export function order0Entropy(samples: Int16Array): number {
166+ const counts = new Int32Array(65536)
167+ for (let i = 0; i < samples.length; i++) counts[samples[i] + 32768]++
168+ let bits = 0
169+ for (const c of counts) {
170+ if (c > 0) {
171+ const p = c / samples.length
172+ bits -= p * Math.log2(p)
173+ }
174+ }
175+ return bits
176+}
177+
178+export interface BoundResult {
179+ /** The prefilter group this bounds, matching the codec groups. */
180+ group: string
181+ note: string
182+ bitsPerSample: number
183+ ratio: number
184+ bytes: number
185+}
186+
187+/** The order-0 bound for each prefilter: raw samples, delta, LPC residual. */
188+export function entropyBounds(samples: Int16Array): BoundResult[] {
189+ const streams: { group: string; what: string; data: Int16Array }[] = [
190+ { group: 'no prefilter', what: 'the samples themselves', data: samples },
191+ { group: 'delta', what: 'the first differences', data: delta(samples) },
192+ { group: 'LPC', what: `the order-${LPC_ORDER} prediction residual`, data: lpcTransform(samples).residual },
193+ ]
194+ return streams.map(({ group, what, data }) => {
195+ const bitsPerSample = order0Entropy(data)
196+ return {
197+ group,
198+ note: `order-0 entropy of ${what} — the limit for a per-sample entropy coder, with no symbol table or coefficients charged`,
199+ bitsPerSample,
200+ ratio: 16 / bitsPerSample,
201+ bytes: Math.ceil((bitsPerSample * samples.length) / 8),
202+ }
203+ })
204+}
205+
159206 let ready: Promise<void> | null = null
160207
161208 // zstd-wasm publishes the *node* build's types while the bundler resolves the
src/worker/compressWorker.tsmodified+11−2View file
@@ -7,6 +7,8 @@ import { LatentSource } from '../model/latent'
77 import {
88 initCodecs,
99 compressAll,
10+ entropyBounds,
11+ type BoundResult,
1012 ZLIB,
1113 ZSTD,
1214 ANS,
@@ -31,6 +33,8 @@ export interface CompressRequest {
3133 export interface CompressResponse {
3234 id: number
3335 results: CodecResult[]
36+ /** Order-0 entropy limit for each prefilter group. */
37+ bounds: BoundResult[]
3438 /** Empirical std of the quantized block, for display sanity. */
3539 empiricalStd: number
3640 error?: string
@@ -54,8 +58,13 @@ self.onmessage = async (e: MessageEvent<CompressRequest>) => {
5458 const mean = sum / samples.length
5559 const empiricalStd = Math.sqrt(Math.max(0, sumSq / samples.length - mean * mean))
5660 const bytes = new Uint8Array(samples.buffer, 0, samples.byteLength)
57- post({ id, results: compressAll(bytes, CODECS), empiricalStd })
61+ post({
62+ id,
63+ results: compressAll(bytes, CODECS),
64+ bounds: entropyBounds(samples),
65+ empiricalStd,
66+ })
5867 } catch (err) {
59- post({ id, results: [], empiricalStd: 0, error: String(err) })
68+ post({ id, results: [], bounds: [], empiricalStd: 0, error: String(err) })
6069 }
6170 }