Commit Graph

9 Commits

Author SHA1 Message Date
teamchong 2b7b98f685 feat(render): add R3 reflow to recover line-end dead margin (~29% glyph fill → dense)
Pack text into a continuous sentinel-delimited stream (↵ = U+21B5) so
wrapLines fills every row to `cols` instead of leaving dead right-margin.
Adds reflow/dereflow, renderTextToPngsReflow{,MultiCol} variants, a full
test suite, and an A/B eval harness with L1 OCR + L2 session results.
2026-05-21 23:14:00 -04:00
Steven Chong d87aecec95 feat(render): use 5x8 code-font atlas 2026-05-21 17:41:30 -04:00
teamchong 9d3984fd03 render: multicol gutter divider (light-gray boundary cue for OCR)
Paint a 1-pixel-wide mid-gray (191/255) vertical line in the center of
each gutter at multicol >= 2. Same 'visible whitespace' principle as the
U+2192 tab arrow — surfaces structure that pure whitespace alone leaves
ambiguous, at near-zero pixel-token cost (DEFLATE collapses identical
mid-gray runs to ~5 bytes per gutter).

Why now: the user noted gutters relied on whitespace alone, which is
exactly the OCR-reordering risk HANDOFF flagged for multi-col 2 (lines
322-326). An explicit boundary cue lets us push wider multicol or wider
cols in future without spooking the vision encoder into reading
left-to-right across columns.

Tests:
  - byte-determinism (existing test still passes)
  - inflate IDAT + assert mid-gray pixels at expected divider X
  - numCols=1 path stays byte-identical to renderTextToPngs (deterministic
    cache_control story for single-col deployments)

Cost: ink=64 pre-invert → 191 post-invert. Doesn't compete with glyph
ink (which lands at 0). At β=0.001776, the divider's ~1568 pixels cost
~3 tokens total per multi-col image. Trivial vs the OCR-clarity win.
2026-05-19 13:24:49 -04:00
teamchong f1688e9f46 transform+render: R2 multi-col default-on + per-request pixel/char instrumentation
Two pieces of work landing together because they're interleaved in
transform.ts and need to ship at the same time for the dashboard
fit-wiring in the next commit to work.

# R2 multi-column rendering (default `multiCol=2`)

Single-col packing wastes ~68% of Anthropic's 1568×1568 image budget
on real Claude Code tool docs (~38 chars/row). The row-aware break-
even gate correctly rejects compression on those slabs — proxy ends
up doing nothing on the heaviest requests. Multi-col packs N text
columns side-by-side per image so each image covers `N×LINES_PER_IMAGE`
wrapped lines, dropping image count by ~N and crossing break-even.

- render.ts: new `renderTextToPngsMultiCol(text, cols, numCols)` with
  column-major layout (col 0 fills top-to-bottom, then col 1, …).
  Plus `multiColWidth` / `maxFittingCols` helpers. CLI auto-clamps
  numCols so the canvas can never exceed the 1568 px width cap.
- transform.ts: `multiCol` option threaded through; gate, image-count
  estimator, paging budget, and all render paths take `numCols`.
  Intro text now describes column reading order when numCols>1 so
  the OCR pass doesn't read row-wise across columns.
- Host wiring: node.ts (--multi-col / MULTI_COL=N, default 2),
  worker.ts (MULTI_COL env, default 2). Startup log shows multi_col=N.
- numCols=1 stays a byte-identical passthrough to renderTextToPngs
  so flipping back can't regress the cache_control hit rate.

# Empirical-cost instrumentation

The dashboard's `OPUS_IMAGE_TOKEN_COST = 2500` and `CHARS_PER_TOKEN = 4`
are stale pre-Opus-4.7 constants. Fact-checked: 4.7 changed the TEXT
tokenizer only (~1.20× inflation on English, ~1.36× on TypeScript);
image cost is unchanged for same-size images. Direction of error in
the dashboard headline: it UNDERSTATES text savings.

Ditched the "guess from one cold-miss" approach in favor of real
instrumentation that pins both axes at once:

- TransformInfo.imagePixels — Σ width×height across every image
  this request (static slab + reminders + tool_results + history
  collapse). Threaded through textToImageBlocks, collapseHistory.
- TransformInfo.outgoingTextChars — every char of TEXT remaining
  in the transformed outgoing body, computed in one walk before
  serialization (countOutgoingTextChars).
- Tracker event schema picks up image_pixels + outgoing_text_chars.
- history.ts: HistoryCollapseInfo.collapsedImagePixels accumulates.

The regression `tokens ≈ α·outgoing_text_chars + β·image_pixels`
over N cold-miss events solves for empirical chars/token (α) and
pixels/token (β) under the live model — no assumed constants.

# Tests

- 8 new render tests covering the multi-col passthrough contract
  (numCols=1 byte-identical to renderTextToPngs), canvas dimensions,
  image-count halving, determinism, charsRendered sum, estimator
  parity, CLI clamp, and CJK wide-glyph wrap.
- paging.test.ts "counts multiple tool_results" threshold loosened
  from >770k to >700k to cover both single-col and multi-col budgets
  at the same maxImagesPerToolResult cap.
- All 241 tests pass, typecheck + build clean.
2026-05-19 11:39:40 -04:00
teamchong 04b1526bd5 transform: large-tool_result paging + adaptive break-even derivation
Two related improvements:

1. Per-tool_result paging / truncation. When a single tool_result would
   render to more than maxImagesPerToolResult images (default 10), the
   source text is truncated head+marker+tail BEFORE rendering. Classifier
   picks the truncation strategy:
     - structured (JSON/YAML/diff): head-only, tail is repeating elements
     - log (timestamps / [LEVEL] prefixes): 60% head + 40% tail
     - other (prose, dumps): same head/tail split
   The marker tells the model what was elided so it can reason about the
   gap: '[ pixelpipe paging: omitted N lines (K chars) ... ]'. Keeps
   requests under Anthropic's 100-image-per-request cap even when a single
   tool dumps a multi-megabyte log.

2. Adaptive CHARS_PER_IMAGE. Previously hardcoded at 14_100 (Unifont 10px
   @ cell 5×11). Now derived at module load from ATLAS_CELL_H + render.ts
   constants. When gen-atlas regenerates with a different font/size, the
   break-even threshold automatically updates: at Cozette 4×7 the chars/
   image jumps from 14.1k → 22.2k and the break-even drops from ~10k → ~3k.
   Single source of truth in src/core/.

Wires:
- TransformOptions.maxImagesPerToolResult (default 10)
- TransformInfo.truncatedToolResults + omittedChars
- TrackEvent.truncated_tool_results + omitted_chars (emit when > 0)
- Two new exported helpers: estimateImageCount, classifyContent, truncateForBudget
- Paging fires before image-encoding at both tool_result branches
  (string content and array-of-text-blocks content)

Tests: +28 paging-specific (paging.test.ts), 199/199 total green.
Bundle delta: negligible (~3 KB raw, ~1 KB gzipped). 488 KB dist gzipped.

Closes the paging-implementer worktree merge that was deferred earlier.
2026-05-18 22:38:15 -04:00
teamchong e328c93d52 atlas: bit-pack pixel storage (1 bit/pixel) for 3.15× bundle shrink
Unifont glyphs are inherently 1-bit (every pixel is 0 or 255). Storing as
8-bit bytes wasted 7 bits per pixel.

Changes:
- scripts/gen-atlas.ts: emit ATLAS_PIXELS as bit-packed MSB-first. OFFSETS
  now indexes bits not bytes. Per-cell paint thresholds R-channel at 128
  defensively (Unifont is already 0/255).
- src/core/render.ts:blitGlyph: byte read replaced with bit extraction
  (~3 extra ops per pixel; negligible). max() blending removed since bit
  data is binary, no AA edges, no glyph overlap.
- src/core/atlas.ts: regenerated with the new format. Header comment
  documents the bit-extraction formula.
- tests/render.test.ts: +1 structural assertion that ATLAS_PIXELS.byteLength
  matches the bit-packed total.

Bundle size:
  atlas.ts raw:    5.47 MB → 1.06 MB  (5.2× shrink)
  atlas.ts gzip:   1.55 MB → 479 KB   (3.2× shrink)
  dist gzipped:    1.57 MB → 488 KB   (3.15× shrink, well under Workers
                                       free-tier 1 MB cap)

Pre-deflate shrink is 8× (theoretical max). The 3.15× post-deflate
result reflects that deflate was already exploiting long runs of 0/255
in the 8-bit data; the bit-packed bytes are denser and less compressible.

Render output is byte-identical to pre-bit-pack (storage format change
only). 108/108 vitest, 4/4 shell tests, typecheck + build clean.
2026-05-18 21:54:07 -04:00
teamchong efcd2a5e3e render: R1 whitespace minify before wrap
Adds minifyForRender() as step 1 of wrapLines() pipeline. Strips trailing
whitespace per line and collapses 4+ consecutive newlines to 3 (= 2 blank
lines). Leading whitespace + mid-line spaces preserved (structural).

HANDOFF item R1 predicted ~1.5-2× chars per image. Empirical measurement
on real corpora (v3 corpus, all .md docs, all .ts source, 2.3 MB live
conversation jsonl) showed 0% reduction — modern editors strip trailing
whitespace on save by default. R1 is shipped as defensive infrastructure
that fires when log-dump-heavy / unminified content arrives.

+9 tests covering trailing-strip, blank-line collapse, structural-space
preservation, and combined-with-tab-expansion pipeline. 107/107 vitest.
2026-05-18 21:40:01 -04:00
teamchong f199640bdc ship Unifont @ 10px multilingual rendering + 400 diagnosis fixes
- Switch font: JetBrains Mono @ 15px → Unifont 16.0.04 @ 10px. Sparse
  atlas with double-width EAW dispatch for CJK. Full BMP profile
  (35,501 glyphs) covers Latin/Cyrillic/Greek/Hebrew/Arabic/CJK/Hangul
  plus Letterlike, Misc Technical, Block Elements, Geometric Shapes,
  Misc Symbols, Dingbats, Enclosed Alphanumerics, all box-drawing.
- Fix cell-height bug (was hardcoded 9; now probed from font metrics
  via Math.ceil(maxAscent+maxDescent)). Previously clipped the top
  2 rows off every CJK glyph since the original Unifont ship.
- Expand tabs to spaces with 4-column tab stops, EAW-aware. Was the
  silent-drop source for 99.6% of dropped chars in production.
- Add dropped_codepoints_top telemetry (top 20 per event, only emitted
  when drops > 0) so future coverage gaps surface in events.jsonl.
- Raise minToolResultChars 2000→5000 and minReminderChars 1000→2000
  to stop net-losing tokens on small compressions, per ~2,500 tok/img
  empirical measurement (N=33 cold-miss events).
- Fix dashboard per-image-cost formula: was pngBytes/375 (~190 tok),
  now imageCount * 2500 (the measured empirical rate). Reduces the
  inflated "tokens saved" headline to honest numbers.
- Schema-stub fix in compressSchemas path: preserveSchemaShell()
  retains type / properties / required / enum / oneOf / anyOf / $ref /
  numeric constraints recursively (depth 20), strips only descriptions
  and metadata. Prevents 400 errors on first-tool-call requests.
- Capture full gzipped 4xx body + per-event req_body_sha8 for upstream
  error diagnosis. Sidecar to ~/.pixelpipe/4xx-bodies/ when over 32 KB
  inline cap. Workers-safe via CompressionStream + Web Crypto.
- scripts/restart.sh: pnpm-aware one-command kill+rebuild+start with
  graceful SIGTERM→SIGKILL escalation, build-failure abort, port-in-use
  precheck, --no-build flag for fast iteration. Shell-tested.
- Bundle 1.58 MB gzipped on Node. 91/91 vitest + 4/4 shell tests.
2026-05-18 21:28:25 -04:00
teamchong f91c149d05 Port to TypeScript with dual Node + Cloudflare Workers targets
Runtime is pure Web Standard APIs (fetch, Request, Response, Uint8Array,
CompressionStream, crypto.subtle, btoa) — zero runtime dependencies.

Core (src/core/, runs identically on Node 18+ and Workers):
  - atlas.ts     auto-generated 9x15 JBM glyph atlas, base64-inlined (17KB)
  - png.ts       minimal grayscale PNG encoder via CompressionStream
  - render.ts    text → packed PNG, soft-wraps at 100 cols, ≤1568px tall
  - transform.ts request body rewriter (system + tool docs → image blocks)
  - proxy.ts     fetch-handler that transforms then forwards to Anthropic
  - types.ts     Anthropic Messages API types we touch

Adapters:
  - src/node.ts     node:http server + CLI flag parsing
  - src/worker.ts   export default { fetch } for wrangler dev/deploy

Build tooling:
  - scripts/gen-atlas.ts   @napi-rs/canvas → atlas.ts (build-time only)
  - scripts/build.mjs      esbuild Node bundle; wrangler handles Worker
  - tsconfig.json          strict, ES2022, Workers types

Tests (vitest): 8 passing — PNG signature, base64 round-trip, single +
multi-image renders, transform no-op + compress paths, billing-line strip,
tool fold + stub.

E2E smoke: 16K char system → 2 PNGs (36KB) → mock upstream, 34ms.

Next: replace docs, then verify byte-output parity against legacy/python.
2026-05-18 15:33:23 -04:00