Files
pxpipe/docs/RENDER_SIZING.md
T
teamchong 8af9829872 feat(render): raise page ceiling to ~1932x1932 and align gate/paging geometry
Fable 5 / Opus 4.8 accept up to 2576px / 4784 visual tokens per image, but a
request with >20 images (pxpipe always sends many) is held to the stricter
<=2000px/side rule, so the real ceiling is ~1932x1932 (1928x1928 = 69x69 = 4761
tokens). MAX_HEIGHT_PX 1568->1932; dense tool/history pages now render at
DENSE_CONTENT_COLS=384 / DENSE_CONTENT_CHARS_PER_IMAGE=92160 (1928x1928 full
page) -- fewer image blocks at the same OCR-validated 5x8 cell. The static slab
is unchanged (313 cols / 1573x1280).

Also fixes a regression the code review caught: the break-even gate,
truncateForBudget, and estimateImageCount predicted against the slab geometry
(313 cols / 159 rows) while the dense renderer emits 384 cols / 240 rows, so
large tool_results were truncated far earlier than the 10-image cap required --
silently dropping output that would have rendered. A per-page char budget is
threaded through the prediction chain (defaults to READABLE so the slab and unit
tests are byte-identical) plus a denseGateGeometry helper, so the gate prices the
same page the renderer actually produces.
2026-06-17 10:01:33 -04:00

9.2 KiB
Raw Blame History

How pxpipe sizes a rendered image — rules, reasons, and history

This documents why a pxpipe PNG has the dimensions it does. It exists because the sizing looks arbitrary until you know what was tried and rejected. If you're tempted to "make it square" or "shrink to the text," read the history first — both were considered, and one was built and reverted.

TL;DR — current behavior

A rendered page is a fixed-width, variable-height image. It is not deliberately squared and never shrunk horizontally to fit short lines.

  • Width is constant per path, set by the column count, not by content: width = 2·PAD_X + cols·CELL_W. The static slab renders at DEFAULT_COLS=3138 + 313·5 = 1573px; dense tool/history pages render at DENSE_CONTENT_COLS=3848 + 384·5 = 1928px.
  • Height grows to fit the lines on the page, capped, then pages: height = 2·PAD_Y + nLines·CELL_H8 + 8·nLines.
  • Vertical cap → paging: maxLines = floor((MAX_HEIGHT_PX 2·PAD_Y) / CELL_H) = floor(1924/8) = 240 lines. Overflow goes to the next image, it does not grow the canvas. A full dense page is 1928 × 1928 (≈square only because 384 cols ≈ 240 rows at the 5×8 cell — not deliberately squared); a full slab page is 1573 × 1280 (~159 rows, READABLE-bound below the 240 cap); a partial page (small tool_result, last page) is wide-and-short, e.g. 1928 × 160.

This ~1932×1932 ceiling is the largest page Fable / Opus 4.8 accept without a server-side resize. Those models take up to 2576 px on the long edge, but a request with >20 images (pxpipe always sends many) is held to the stricter ≤2000 px/side rule, and 1928×1928 = 69×69 = 4761 visual tokens, just under the 4784-token per-image cap. Going bigger gets the request rejected, not downscaled.

Source of truth: renderChunkToPng in src/core/render.ts (the width / height lines), constants PAD_X=PAD_Y=4, CELL_W=5, CELL_H=8 (the "5×8 cell"), DEFAULT_COLS=313, DENSE_CONTENT_COLS=384, MAX_HEIGHT_PX=1932, READABLE_CHARS_PER_IMAGE=50000, DENSE_CONTENT_CHARS_PER_IMAGE=92160.

The cell

Each character occupies a 5px wide × 8px tall cell (ATLAS_CELL_W=5, ATLAS_CELL_H=8 in src/core/atlas.ts). The atlas is a prebaked glyph sheet; text is rendered white-on-black then inverted to black-on-white. The cell was 7×10 originally and shrunk to 5×8 — smaller cell, same legibility on the target model, more chars per pixel.

Why full width, not shrink-to-content

shrinkColsToContent (src/core/render.ts) still exists but is a no-op — it returns cols unchanged. Its docstring records the policy:

Always render at full canvas width — no shrink-to-content. Maximum chars per page = maximum image-token savings on dense content, and the unused canvas tail is just whitespace (cheap to encode).

The reasoning:

  1. The only content pxpipe images is dense content (it passes a profitability gate; sparse/short-line content is left as text). After reflow packs that content into full-width rows, the longest line is already ~cols, so there is nothing left to shrink. Width-shrink only helps short-line content — which never reaches the renderer.
  2. Simplicity + prediction parity. The cost gate has to predict image tokens before rendering. A fixed width makes prediction trivial and exact; a content-dependent width forced the gate to re-derive the canvas size (see the "hoist width-shrink before gate" commit below) for no real-world gain.

Why not "square with max width"

Anthropic bills images by pixel area (≈ w·h/750), not by the longest edge. So a square is not cheaper than a wide-short image of the same area — what minimizes cost is the tightest bounding box around the text, and for dense reflowed content that box is exactly "full width × just enough rows," which is what we render. We never pad to a square: a full dense page lands at 1928×1928 only because 384 cols and 240 rows are equal pixel extents at the 5×8 cell — it's the max-chars-per-page point (the ~1932² token ceiling), not an aspect-ratio target, and a partial page stays wide-and-short. Aspect ratio is a non-goal; chars-per-pixel is the goal, achieved by filling every row to cols and paging vertically.

Two render paths

  • tool_result / history images: single-column at DENSE_CONTENT_COLS=384 (1928px wide), paged at the 240-line cap → full pages are 1928×1928 (~92k chars each).
  • system-slab image: single-column at DEFAULT_COLS=313 (1573px wide), READABLE-bound to ~159 rows (~50k chars → 1573×1280). Kept on a path that can use multi-column packing (shrinkWidth=false), but multi-col is disabled by default (multiCol: 1) because at 313 cols a single column already holds ~50k chars/page and multi-col adds OCR column-ordering risk without meaningful savings.

So in practice everything is single-column full-width today; the multi-col code is retained for backward compat.

Billing model (and an unresolved gap)

  • Anthropic's documented formula: tokens ≈ (w·h) / 750.
  • The gate's constant: src/core/transform.ts anchors per-image cost on an empirical figure (~2500 tok for a 508×1559 canvas, i.e. claiming the doc formula underpredicts billing by ~2.4×). That measurement is from an earlier cell/column regime.
  • A fresh regression on production ~/.pxpipe/events.jsonl (2-var OLS, tokens ≈ a·text_chars + b·image_pixels, N≈1.5k cold-miss events) gives ~907 pixels/token and ~1.8 chars/token for text — i.e. much closer to the doc's 750 than to the gate's stale ~312 px/tok.

These disagree by ~3×. The gate is therefore likely pessimistic about image cost on current traffic (it may pass up profitable compressions). Re-grounding TOKENS_PER_IMAGE from a fresh regression is an open task — the data is in the event log (image_pixels next to input_tokens + cache_create_tokens on cold-miss rows). Don't trust any single hardcoded image-cost constant without re-checking it against the log.

How sizing decisions are/were measured

  • L1 OCR eval (eval/eval-l1-ocr.mjs): per-character read accuracy across render styles (cell size, reflow on/off, instruction placement, grayscale). This is what gated reflow and the in-image instruction banner.
  • L2 session eval (eval/eval-l2-session.mjs): comprehension over whole imaged sessions.
  • events.jsonl regression: the real per-pixel/per-char billing fit above.
  • glyph-matrix (eval/glyph-matrix/, PAUSED): a per-character confusion matrix across render styles, to find a zero-cost style that lowers read error so the gate can compress harder at the same fidelity. Paused because the reader model (Fable 5) is offline.

History (oldest → newest)

The sizing converged through measured iteration, not a single design. Key commits:

date commit change why
2026-05-21 38e852a add R3 reflow (recover line-end dead margin, ~29% glyph fill → dense) rows were mostly empty; pack them
2026-05-22 fbf32bb pack reflow across newlines + grayscale atlas + build L1/L2 eval harness measure read fidelity of the packing
2026-05-22 ea68340 in-image instruction banner variant L1 eval: +1.04pp char accuracy vs baseline
2026-05-22 dca7807 pack header prose to full row width, drop spurious ↵ markers tidy reflow output
2026-05-22 a9b0996 docs: cell is now 5×8 (was 7×10) denser cell
2026-05-23 1afaa6c content-aware image cost + width-shrinking (WIP) tried shrink-to-content
2026-05-23 414f4bf hoist width-shrink before the cost gate so prediction matches render make shrink predictable
2026-05-25 3c8716c full-canvas single-column rendering, 50k chars/pageshrinkColsToContent becomes a no-op shrink gave no gain on dense content; full width packs more
2026-05-25 bb8e0d8 page dense tool/history images enforce the 195-line cap, split overflow
2026-05-26 28bc65c reduce dense page size tuning
2026-06-09 cdfc99d drop Opus, Fable-5 only; dense render on bare 5×8 cell Opus misread ~7% of renders
2026-06-17 (this change) raise page ceiling to ~1932×1932: MAX_HEIGHT_PX=1932, dense pages DENSE_CONTENT_COLS=384 / 92160 chars; row cap follows each call's char budget Fable/Opus 4.8 accept ≤2000px & ≤4784 tok/image (not the old 1568 limit); baseline left ~3× headroom unused → bigger pages = fewer image blocks at the same 5×8 legibility

The arc: reflow to stop wasting rows → eval harness to prove the packing is still readable → width-shrink experiment → reverted to full-canvas two days later because dense content already fills the width → paging for the height cap → model scope narrowed to the reader that hits 100% on the cell.

If you want to change the sizing

  1. Add a style variant to the L1 OCR eval and measure char accuracy first.
  2. Check the cost side against events.jsonl (don't trust the hardcoded per-image constant — see the gap above).
  3. Remember the gate must be able to predict the size cheaply; content-dependent geometry is what got width-shrink reverted.