fix(render): glyph surgery so Spleen 5x8 K no longer reads as H (#127)

* fix(render): glyph surgery so Spleen 5x8 K no longer reads as H

The stock Spleen K was H with one crossbar pixel removed (Hamming 1), the
worst confusable pair in the atlas. Because the API downscales images and the
model infers rather than scans, that difference vanishes and K is silently
misread as H - with no signal of uncertainty.

gen-atlas.ts now repaints K with a diagonal-legged bitmap: Hamming 8 from H,
and >=6 from every other ASCII glyph. Scoped to the narrow Spleen 5x8 primary
cell, so the JetBrains-Mono and CJK-fallback atlases are untouched. Zero
token-cost change - the glyph occupies the same 5x8 box.

Guarded by a regression test asserting no alphanumeric pair sits below
Hamming 2. Reproduce with: npx tsx eval/glyph-matrix/demo-glyph.mts

Closes the "optional glyph surgery" item in the 2026-07-01 legibility audit.
This commit is contained in:
dhruvraajeev
2026-07-20 23:31:24 -05:00
committed by GitHub
parent 5e324fba09
commit 41f2ed1d39
8 changed files with 224 additions and 13 deletions
+27
View File
@@ -4,6 +4,33 @@ All notable changes to pxpipe are documented here. This project adheres to
[Semantic Versioning](https://semver.org/) (pre-1.0: minor = features /
behavioral changes, patch = fixes).
## Unreleased — 2026-07-19
### Fixed
- **Glyph surgery: the Spleen 5×8 `K` no longer reads as `H`.** The stock font
rendered `K` as `H` with a single crossbar pixel removed — Hamming distance 1,
the worst confusable pair in the atlas
([legibility audit §2](docs/LEGIBILITY-AUDIT-2026-07-01.md)). `gen-atlas.ts`
now repaints `K` with a diagonal-legged bitmap (Hamming 8 from `H`; no
alphanumeric pair below d=2). Scoped to the narrow Spleen 5×8 primary cell, so
the JetBrains-Mono and CJK-fallback atlases are untouched. Zero token-cost
change (same 5×8 box). Regression-guarded in `tests/render.test.ts`.
### Known limitations / evidence
- **Model-level A/B validation (2026-07-19, `claude-fable-5`, direct API):**
paired reads of identical seeded fixtures rendered with each atlas
(2 arms × 8 pages × 3 reps = 48 reads; base `b754d95` vs this PR `ab063f2`).
H/K per-char error 119/252 (47.2%) → 47/252 (18.7%); `K``H` confusions
42 → 1; paired per-position McNemar exact p = 2.4e-07. No collateral
regression: non-H/K error 32.2% → 31.7%, identical-image control pages at
run-variance floor. Full write-up in
[FINDINGS.md](FINDINGS.md) ("Update (2026-07-19)").
- Reproduce directly — no fixtures or screenshots needed:
`npx tsx eval/glyph-matrix/demo-glyph.mts` prints the `K`/`H` bitmaps before
(stock font) and after (shipped atlas) with their Hamming distance.
- The known `,`/`;` and `.`/`:` pairs remain 1 px apart and are out of scope; the
new regression guard enforces d≥2 across letters and digits only.
## 0.9.0 — 2026-07-14
### Changed
+27 -1
View File
@@ -1,13 +1,39 @@
# FINDINGS — pxpipe (text→PNG token compression)
**Status:** ⚠️ **VERDICT REVERSED — see correction below.** Originally ruled "dead"; live measurement shows pxpipe is a working *lossy gist-compressor* saving ~68% on real (dense) Claude Code traffic, with a known verbatim-recall gap.
**Date:** 2026-05-28 (original) · 2026-05-29 (correction) · 2026-06-09 (Fable 5 update) · 2026-06-10 (gist-recall A/B, SWE-bench pilot) · 2026-06-12 (field observation, n=1) · 2026-06-23 (reframe: correct baseline = /compact) · 2026-07-09 (GPT-5.6 Sol raw-recall pilot)
**Date:** 2026-05-28 (original) · 2026-05-29 (correction) · 2026-06-09 (Fable 5 update) · 2026-06-10 (gist-recall A/B, SWE-bench pilot) · 2026-06-12 (field observation, n=1) · 2026-06-23 (reframe: correct baseline = /compact) · 2026-07-09 (GPT-5.6 Sol raw-recall pilot) · 2026-07-19 (K/H glyph-surgery model-level A/B)
**Models tested:** `claude-opus-4-5` (original run), `claude-opus-4-8` (re-test after a model bump), `claude-fable-5` (2026-06-09), `gpt-5.6-sol` (2026-07-09 raw-image pilot)
**Model scope (current):** Fable 5 only. Sol, Opus, GPT 5.5, and Grok remain explicit opt-ins.
**Harnesses:** Claude/Opus/Fable: `eval/needle-haystack/` (older receipts preserved from `/tmp/needle_eval`); Sol: `eval/sol-profile/` (raw responses and receipts committed)
---
## Update (2026-07-19) — Spleen 5×8 K/H glyph surgery validated at model level (PR #127)
Paired before/after benchmark of the `K` glyph repaint, read by `claude-fable-5`
via direct API (`env -u ANTHROPIC_BASE_URL`, pxpipe fully bypassed). Arms: base
`b754d95295f34772eee385ea5e1bdc62c2f98ee8` (origin/main) vs PR head
`ab063f268482ef7cb28f88370bf1b2c9780ffa29`. Identical seeded fixtures rendered
with each atlas: 2 arms × 8 pages × 3 reps = 48 reads; pages 05 differ only by
atlas, control pages 67 are byte-identical across arms (per-page sha256
receipts retained).
| metric (best-assignment alignment, 252 H/K chars per arm) | base | PR #127 |
|---|---|---|
| H/K per-char error | 119/252 = **47.2%** [41.1, 53.4] | 47/252 = **18.7%** [14.3, 23.9] |
| `K``H` confusions | **42** | **1** |
| non-H/K char error | 32.2% [29.6, 34.9] | 31.7% [29.1, 34.4] |
| control-ID exact match | 5/48 | 5/48 |
Paired per-position McNemar (same gold/page/rep/position): 92 PR-only-correct
vs 34 base-only-correct, discordant n = 126, **exact two-sided p = 2.4e-07**.
H/K char accuracy 51.6% → 74.6%. No collateral regression: non-H/K error flat,
zero `H``K` confusions introduced, identical-image controls at run-variance
floor. Verdict: merge — the surgery fixes exactly the claimed failure mode at
the model level, not just in atlas Hamming distance.
---
## Update (2026-07-17) — Sol 5×8 evaluated, remains opt-in
The exact `gpt-5.6-sol` profile now uses Spleen 5×8 at 152 columns. It remains
+9 -2
View File
@@ -101,8 +101,15 @@ converts a coin-flip-per-glyph read into a 100% text quote, at ~5% of source cha
model-callable "rehydrate this region as text" tool is the real answer for
**dense** exact-recall (code files, large tables) that no fixed-size sidecar can
hold. This is the "put a glass over it" mechanism, and it's half-built.
5. **Optional — glyph surgery** on `H~K` (d=1 defect) and case-contrast for the S/s
class. Zero token cost, bounded upside; do it in the same eval harness as (3).
5. **Done — glyph surgery on `H~K` (d=1 defect).** `gen-atlas.ts` now repaints
the stock Spleen `K` (which was `H` minus one crossbar pixel) with a
diagonal-legged bitmap; `H~K` is now Hamming 8, and no alphanumeric pair
sits below d=2. Regression-guarded in `tests/render.test.ts`. **Validated at
model level (2026-07-19):** paired `claude-fable-5` A/B on seeded H/K
fixtures — H/K per-char error 47.2% → 18.7%, `K``H` confusions 42 → 1,
McNemar exact p = 2.4e-07, no non-H/K regression (see FINDINGS.md,
"Update (2026-07-19)"). Case-contrast for the S/s class remains open; do it
in the same eval harness as (3).
## Verdict on "is the factsheet the way, or should there be a RAG escape?"
+62
View File
@@ -0,0 +1,62 @@
/**
* Demo A the K/H glyph fix, before vs after.
* BEFORE = rasterized straight from the stock Spleen font file (unchanged in assets/).
* AFTER = decoded out of the shipped atlas.ts (which now carries the surgery).
* Run: npx tsx eval/glyph-matrix/demo-glyph.mts
*/
import { createCanvas, GlobalFonts } from '@napi-rs/canvas';
import { readFileSync } from 'node:fs';
import {
atlasRank, ATLAS_CELL_W, ATLAS_CELL_H, ATLAS_OFFSETS, ATLAS_PIXELS, ATLAS_WIDE_FLAGS,
} from '../../src/core/atlas.js';
const W = ATLAS_CELL_W, H = ATLAS_CELL_H;
GlobalFonts.register(readFileSync('assets/Spleen-5x8.otb'), 'SpleenDemo');
function fromFont(ch: string): number[] {
const c = createCanvas(W, H), ctx = c.getContext('2d');
ctx.font = `8px SpleenDemo`; ctx.textBaseline = 'alphabetic';
ctx.fillStyle = '#000'; ctx.fillRect(0, 0, W, H);
ctx.fillStyle = '#fff'; ctx.fillText(ch, 0, 7);
const img = ctx.getImageData(0, 0, W, H);
return Array.from({ length: W * H }, (_, p) => (img.data[p * 4]! >= 128 ? 1 : 0));
}
function fromAtlas(ch: string): number[] {
const rank = atlasRank(ch.codePointAt(0)!);
const base = ATLAS_OFFSETS[rank]!;
const w = ATLAS_WIDE_FLAGS[rank] ? 2 * W : W;
return Array.from({ length: w * H }, (_, p) => {
const bit = base + p;
return (ATLAS_PIXELS[bit >>> 3]! >>> (7 - (bit & 7))) & 1;
});
}
const ham = (a: number[], b: number[]) => a.reduce((d, v, i) => d + (v !== b[i] ? 1 : 0), 0);
function render(bits: number[], w = W): string[] {
return Array.from({ length: H }, (_, y) =>
Array.from({ length: w }, (_, x) => (bits[y * w + x] ? '#' : '.')).join(''));
}
function sideBySide(title: string, pairs: Array<[string, number[]]>) {
console.log(`\n${title}`);
console.log(pairs.map(([n]) => n.padEnd(W + 3)).join(''));
const rows = pairs.map(([, b]) => render(b));
for (let y = 0; y < H; y++) console.log(rows.map((r) => r[y]!.padEnd(W + 3)).join(''));
}
const beforeK = fromFont('K'), beforeH = fromFont('H');
const afterK = fromAtlas('K'), afterH = fromAtlas('H');
sideBySide('BEFORE (stock Spleen font)', [['H', beforeH], ['K', beforeK]]);
console.log(` → Hamming(H,K) = ${ham(beforeH, beforeK)} ${ham(beforeH, beforeK) <= 1 ? '⚠️ indistinguishable after downscaling' : ''}`);
sideBySide('AFTER (shipped atlas.ts)', [['H', afterH], ['K', afterK]]);
console.log(` → Hamming(H,K) = ${ham(afterH, afterK)} ✅ unmistakable`);
// Nearest neighbour of the new K across all printable ASCII.
let worst = { ch: '', d: Infinity };
for (let cp = 0x21; cp <= 0x7e; cp++) {
const ch = String.fromCharCode(cp);
if (ch === 'K') continue;
const d = ham(afterK, fromAtlas(ch));
if (d < worst.d) worst = { ch, d };
}
console.log(`\nNew K's closest neighbour across ASCII: '${worst.ch}' at Hamming ${worst.d} (safe if ≥ 2)`);
+44 -2
View File
@@ -254,11 +254,42 @@ for (const src of [contexts.primary, contexts.fallback]) {
}
}
// --- Glyph surgery ---------------------------------------------------------
// A few stock Spleen 5×8 cells are pathologically confusable with another
// glyph at the atlas's pixel budget (see docs/LEGIBILITY-AUDIT-2026-07-01.md
// §2). We repaint those cells with a hand-tuned bitmap. Keyed by codepoint;
// each value is exactly `cellH` rows of `cellW` chars ('#' = ink). Applied
// ONLY to the narrow primary Spleen 5×8 cell, so the JetBrains-Mono (6×11)
// and CJK-fallback atlases are never touched.
//
// U+004B 'K': stock Spleen K is 'H' with one crossbar pixel removed (Hamming
// distance 1 — the worst confusable pair in the atlas). This diagonal-legged
// K sits at Hamming ≥6 from every other ASCII glyph.
const GLYPH_OVERRIDES: Record<number, readonly string[]> = {
0x4b: ['.....', '#..#.', '#.#..', '##...', '##...', '#.#..', '#..#.', '.....'],
};
const SURGERY_OK = cellW === 5 && cellH === 8;
function overrideCoverage(cp: number): Uint8Array | null {
const rows = GLYPH_OVERRIDES[cp];
if (!rows) return null;
if (rows.length !== cellH || rows.some((r) => r.length !== cellW)) {
throw new Error(
`[gen-atlas] glyph override U+${cp.toString(16).toUpperCase()} must be ` +
`${cellW}×${cellH}, got ${rows[0]?.length}×${rows.length}.`,
);
}
const cov = new Uint8Array(cellW * cellH);
for (let y = 0; y < cellH; y++)
for (let x = 0; x < cellW; x++) cov[y * cellW + x] = rows[y]![x] === '#' ? 255 : 0;
return cov;
}
const codepoints = new Uint32Array(found.length);
const offsets = new Uint32Array(found.length);
const wideFlags = new Uint8Array(found.length);
const cellBitSlices: Uint8Array[] = [];
let totalBits = 0;
let surgeryApplied = 0;
// Gray mode: collect raw coverage bytes (0-255) per pixel, 1 byte per pixel.
// Used only when ATLAS_GRAY=1; the 1-bit path is unchanged when GRAY_MODE is false.
@@ -280,23 +311,34 @@ for (let i = 0; i < found.length; i++) {
const img = ctx.getImageData(0, 0, w, cellH);
// Glyph surgery: swap in the hand-tuned bitmap for confusable narrow Spleen
// cells. `ov[p]` (0/255) stands in for the canvas R-channel byte everywhere.
const ov = SURGERY_OK && source === 'primary' && !wide ? overrideCoverage(cp) : null;
if (ov) surgeryApplied++;
if (GRAY_MODE) {
// Gray mode: record the raw R-channel coverage byte (canvas is white-on-black).
offsets[i] = grayTotalBytes; // BYTE offset (not bit offset)
const coverage = new Uint8Array(w * cellH);
for (let p = 0; p < coverage.length; p++) coverage[p] = img.data[p * 4]!;
for (let p = 0; p < coverage.length; p++) coverage[p] = ov ? ov[p]! : img.data[p * 4]!;
graySlices.push(coverage);
grayTotalBytes += coverage.length;
} else {
// 1-bit mode (default / production path): threshold and bit-pack.
offsets[i] = totalBits;
const bits = new Uint8Array(w * cellH);
for (let p = 0; p < bits.length; p++) bits[p] = img.data[p * 4]! >= 128 ? 1 : 0;
for (let p = 0; p < bits.length; p++) bits[p] = (ov ? ov[p]! : img.data[p * 4]!) >= 128 ? 1 : 0;
cellBitSlices.push(bits);
totalBits += bits.length;
}
}
if (SURGERY_OK)
console.log(
`[gen-atlas] glyph surgery: ${surgeryApplied}/${Object.keys(GLYPH_OVERRIDES).length} ` +
`override(s) applied (Spleen 5×8 narrow cells).`,
);
function bytesB64(bytes: Uint8Array): string {
return Buffer.from(bytes).toString('base64');
}
File diff suppressed because one or more lines are too long
+4 -4
View File
File diff suppressed because one or more lines are too long
+47
View File
@@ -31,6 +31,7 @@ import {
atlasRank,
ATLAS_CELL_H,
ATLAS_CELL_W,
ATLAS_OFFSETS,
ATLAS_PIXELS,
ATLAS_WIDE_FLAGS,
ATLAS_NUM_GLYPHS,
@@ -70,6 +71,52 @@ describe('model-selectable font atlases', () => {
});
// Spleen 5×8 glyph-confusability guard. See docs/LEGIBILITY-AUDIT-2026-07-01.md
// §2: the stock K was 'H' minus one crossbar pixel (Hamming 1), the worst pair
// in the atlas. gen-atlas.ts now surgeries K to a diagonal-legged bitmap.
describe('Spleen 5×8 glyph confusability', () => {
const decode = (ch: string): Uint8Array => {
const rank = atlasRank(ch.codePointAt(0)!);
expect(rank).toBeGreaterThanOrEqual(0);
const wide = ATLAS_WIDE_FLAGS[rank] === 1;
const w = wide ? 2 * ATLAS_CELL_W : ATLAS_CELL_W;
const base = ATLAS_OFFSETS[rank]!;
const bits = new Uint8Array(w * ATLAS_CELL_H);
for (let p = 0; p < bits.length; p++) {
const bit = base + p;
bits[p] = (ATLAS_PIXELS[bit >>> 3]! >>> (7 - (bit & 7))) & 1;
}
return bits;
};
const hamming = (a: Uint8Array, b: Uint8Array): number => {
let d = 0;
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) d++;
return d;
};
it('renders K far from H (surgery applied)', () => {
expect(hamming(decode('K'), decode('H'))).toBeGreaterThanOrEqual(6);
});
it('keeps every alphanumeric pair distinguishable (Hamming ≥ 2)', () => {
// Letters and digits are the recall-critical classes (identifiers, hex);
// this is where the H/K d=1 defect lived. Punctuation pairs the audit
// listed but left unfixed (',;' '.:' at d=1) are out of scope here.
const chars =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'.split('');
const glyphs = chars.map(decode);
let worst = { a: '', b: '', d: Infinity };
for (let i = 0; i < glyphs.length; i++)
for (let j = i + 1; j < glyphs.length; j++) {
const d = hamming(glyphs[i]!, glyphs[j]!);
if (d < worst.d) worst = { a: chars[i]!, b: chars[j]!, d };
}
// No two distinct alphanumeric glyphs may collide (d=0) or differ by a
// single pixel (d=1, the K/H defect the surgery removed).
expect(worst.d, `closest pair '${worst.a}'~'${worst.b}' d=${worst.d}`).toBeGreaterThanOrEqual(2);
});
});
describe('compactSlabWhitespace', () => {
it('returns empty string unchanged', () => {
expect(compactSlabWhitespace('')).toBe('');