mirror of
https://github.com/teamchong/pxpipe.git
synced 2026-07-22 02:02:51 +02:00
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:
@@ -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
@@ -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 0–5 differ only by
|
||||
atlas, control pages 6–7 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
|
||||
|
||||
@@ -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?"
|
||||
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because one or more lines are too long
@@ -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('');
|
||||
|
||||
Reference in New Issue
Block a user