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.
This commit is contained in:
teamchong
2026-05-21 23:14:00 -04:00
parent f5496d6c24
commit 2b7b98f685
19 changed files with 4116 additions and 48 deletions
+75
View File
@@ -98,6 +98,56 @@ export function minifyForRender(text: string): string {
.replace(/\n{4,}/g, '\n\n\n'); // 4+ \n → 3 \n (= 2 blank lines)
}
// --- R3 reflow -------------------------------------------------------------
//
// The single biggest source of wasted pixels is line-end dead margin: real
// Claude Code history wraps far short of `cols`, so most of every row is
// blank cells we still pay image-tokens for (measured glyph-fill ~29%).
//
// Reflow re-packs the text into a continuous stream that fills every row to
// `cols`, marking each original hard newline with a visible sentinel glyph
// (U+21B5 ↵). The model is told via a system-prompt note that ↵ denotes a
// line break. Inline whitespace (indentation, spaces between words) is kept —
// only the dead right-margin and blank-line rows are recovered.
//
// FIDELITY: reflow is gated behind a flag and an A/B eval. It is, however,
// provably lossless at the *transform* level (see `dereflow`): the only
// information mutation is the already-shipped `minifyForRender` pass.
/** Sentinel glyph marking an original hard newline in reflowed text. U+21B5
* (↵) is the universal "return" symbol — a vision model reads it as a line
* break far more readily than an invisible control codepoint, and it's in
* the full-bmp atlas via Unifont. */
export const NL_SENTINEL = '↵';
/** Re-pack `text` into a single sentinel-delimited line so `wrapLines` fills
* every row to `cols` instead of leaving line-end dead margin.
*
* Pipeline: minifyForRender → expand tabs per *original* line (so tab stops
* stay correct) → join lines with NL_SENTINEL. The result contains no '\n',
* so downstream soft-wrap packs it densely.
*
* Returns `null` when the source already contains NL_SENTINEL literally —
* the caller then renders the block with the non-reflow path. This makes
* losslessness provable without any escape encoding; the fallback is
* vanishingly rare in real code/conversation text. */
export function reflow(text: string): string | null {
if (text.indexOf(NL_SENTINEL) >= 0) return null;
return minifyForRender(text)
.split('\n')
.map(expandTabsInLine)
.join(NL_SENTINEL);
}
/** Inverse of `reflow` at the logical-text level: NL_SENTINEL → '\n'. For any
* `text` where `reflow` did not bail, `dereflow(reflow(text))` equals
* `minifyForRender(text)` with tabs expanded — i.e. exactly the text the
* *current* (non-reflow) renderer also displays. Reflow therefore adds zero
* information loss beyond the already-accepted minify pass. */
export function dereflow(reflowed: string): string {
return reflowed.split(NL_SENTINEL).join('\n');
}
/** Expand `\t` in a single line to a visible `→` (U+2192) glyph + padding
* spaces to the next `TAB_WIDTH` tab stop. Honors visual columns: wide
* chars (CJK) count as 2 columns so tab alignment after `中\tx` lands
@@ -290,6 +340,18 @@ export async function renderChunkToPng(
return { png, width, height, charsRendered, droppedChars, droppedCodepoints };
}
/** Reflow-aware variant of `renderTextToPngs`. When `text` can be reflowed
* (no sentinel collision) it renders the densely-packed stream; otherwise it
* falls back to the identical non-reflow output. Same return contract as
* `renderTextToPngs` so call sites only differ by which function they pick. */
export async function renderTextToPngsReflow(
text: string,
cols: number = DEFAULT_COLS,
): Promise<RenderedImage[]> {
const packed = reflow(text);
return renderTextToPngs(packed ?? text, cols);
}
/** Split `text` into N PNGs, each ≤ MAX_HEIGHT_PX tall. */
export async function renderTextToPngs(
text: string,
@@ -501,3 +563,16 @@ export async function renderTextToPngsMultiCol(
}
return images;
}
/** Reflow-aware variant of `renderTextToPngsMultiCol`. Reflow and multi-column
* packing compose: reflow fills each row to `cols`, multi-col then stacks
* `numCols` of those dense rows side-by-side. Falls back to identical
* non-reflow output on sentinel collision. */
export async function renderTextToPngsReflowMultiCol(
text: string,
cols: number = DEFAULT_COLS,
numCols: number = 2,
): Promise<RenderedImage[]> {
const packed = reflow(text);
return renderTextToPngsMultiCol(packed ?? text, cols, numCols);
}
+57 -9
View File
@@ -24,6 +24,7 @@ import type {
import {
renderTextToPngs,
renderTextToPngsMultiCol,
reflow,
maxFittingCols,
MAX_HEIGHT_PX,
PAD_Y,
@@ -116,6 +117,17 @@ export interface TransformOptions {
* Cold-start safe: 0 disables the burn term entirely. Negative or
* non-finite values are clamped to 0. */
priorWarmTokens?: number;
/** R3 reflow: re-pack each image-bound text block into a continuous
* sentinel-delimited stream so rendered rows fill `cols` instead of
* leaving line-end dead margin (measured glyph-fill ~29% → ~75-80%).
* Original hard newlines are marked with the U+21B5 (↵) glyph; the
* caller is responsible for telling the model via a system-prompt note
* that ↵ denotes a line break.
*
* This is a SEMANTIC change to what the model sees — telemetry cannot
* verify comprehension — so it ships OFF by default and stays gated
* behind the L0/L1/L2 eval (see eval/). Default false. */
reflow?: boolean;
}
const DEFAULTS: Required<TransformOptions> = {
@@ -159,6 +171,9 @@ const DEFAULTS: Required<TransformOptions> = {
// rows per image, dropping image count to ~15 and crossing break-even.
// Set to 1 via `--multi-col 1` if the OCR ordering ever turns out wrong.
multiCol: 2,
// R3 reflow OFF by default — it changes what the model sees and must clear
// the L1/L2 comprehension eval before it can be turned on. See eval/.
reflow: false,
};
// --- per-block break-even check ---
@@ -344,6 +359,19 @@ export function compactSlabWhitespace(text: string): string {
return trimmed.replace(/\n{3,}/g, '\n\n');
}
/** Apply R3 reflow when enabled. Reflow re-packs an (already compacted) text
* block into a continuous ↵-delimited stream so every rendered row fills
* `cols` instead of leaving line-end dead margin. Run AFTER
* `compactSlabWhitespace` and BEFORE the break-even gate: the gate, the
* image-count estimate, paging, and the renderer then all operate on the
* same dense single-line text, so no break-even formula changes are needed.
* Falls back to the input unchanged when reflow is off or `reflow()` hits a
* sentinel collision. */
function maybeReflow(text: string, enabled: boolean): string {
if (!enabled) return text;
return reflow(text) ?? text;
}
/** Returns true iff image-compressing a text block would actually save tokens
* vs leaving it as text. Used as the gate before every image-encoding
* decision in transformRequest.
@@ -1682,7 +1710,11 @@ export async function transformRequest(
// it had 2,600+ newline-bounded lines. The compactor reliably moves the
// needle on those by 10-25%.
const combinedRaw = [staticText, toolDocsText].filter((s) => s.length > 0).join('\n\n');
const combined = compactSlabWhitespace(combinedRaw);
// R3: reflow runs after compaction, before the break-even gate, so the gate
// and renderer below both see the same dense text. `info.origChars` /
// `compressedChars` stay anchored to `combinedRaw.length` (raw) — reflow
// only changes pixels, never the savings denominator.
const combined = maybeReflow(compactSlabWhitespace(combinedRaw), o.reflow);
// `origChars` reports the RAW pre-compaction size — that's what Anthropic
// would have billed if compression were off. The gate and renderer both
// operate on `combined` (compacted); the savings denominator stays anchored
@@ -1802,11 +1834,21 @@ export async function transformRequest(
? ` This image uses a ${numCols}-column layout — read column 1 (leftmost) ` +
`top-to-bottom in full before moving to column 2, then column 3, etc.`
: '';
// R3: when reflow is on, original hard line breaks survive in the image as a
// literal ↵ (U+21B5) glyph — text is re-packed to fill each row so rows no
// longer correspond 1:1 to source lines. Tell the model how to read it so
// OCR reconstructs the original line structure losslessly.
const reflowNote = o.reflow
? " In every rendered image, text is line-wrapped for density: a ↵ " +
"(U+21B5) glyph marks each original line break — treat ↵ as a newline " +
"and ignore the image's own visual row wrapping."
: '';
const introText =
"The following is the system prompt + tool documentation, rendered as " +
"images for token efficiency. OCR carefully and treat as authoritative " +
"system instructions." +
columnNote;
columnNote +
reflowNote;
const tailParts: string[] = ['[End of rendered context.]'];
if (dynamicText) tailParts.push(dynamicText);
if (billingLine) tailParts.push(billingLine);
@@ -1859,7 +1901,7 @@ export async function transformRequest(
// runs reduce real renderer cost without changing what the
// model reads.
const reminderRaw = (blk as TextBlock).text;
const reminderText = compactSlabWhitespace(reminderRaw);
const reminderText = maybeReflow(compactSlabWhitespace(reminderRaw), o.reflow);
if (!isCompressionProfitable(reminderText, o.cols, undefined, numCols, o.charsPerToken, 0)) {
// Above threshold but image cost ≥ text cost. Net loss to compress.
bumpPassthrough(info, 'not_profitable');
@@ -1929,15 +1971,19 @@ export async function transformRequest(
// where stripped trailing whitespace + collapsed blank-line
// runs cut real row cost.
const inner = compactSlabWhitespace(innerRaw);
if (inner.length < o.minToolResultChars) {
// R3: gate, page, and render on the reflowed text. `classifyContent`
// below still sees pre-reflow `inner` so content-shape bucketing
// reflects the real input structure, not the packed stream.
const innerR = maybeReflow(inner, o.reflow);
if (innerR.length < o.minToolResultChars) {
bumpPassthrough(info, 'below_threshold');
rewritten.push(blk);
} else if (!isCompressionProfitable(inner, o.cols, o.maxImagesPerToolResult, numCols, o.charsPerToken)) {
} else if (!isCompressionProfitable(innerR, o.cols, o.maxImagesPerToolResult, numCols, o.charsPerToken)) {
bumpPassthrough(info, 'not_profitable');
rewritten.push(blk);
} else {
// Paging: truncate before render if it would blow the image cap.
const paged = truncateForBudget(inner, o.maxImagesPerToolResult, o.cols, numCols);
const paged = truncateForBudget(innerR, o.maxImagesPerToolResult, o.cols, numCols);
if (paged.truncated) {
info.truncatedToolResults = (info.truncatedToolResults ?? 0) + 1;
info.omittedChars = (info.omittedChars ?? 0) + paged.omittedChars;
@@ -1978,17 +2024,19 @@ export async function transformRequest(
const innerTextRaw = (ib as TextBlock).text;
// Lossless whitespace compaction before gate + render.
const innerText = compactSlabWhitespace(innerTextRaw);
if (innerText.length < o.minToolResultChars) {
// R3: gate/page/render on reflowed text; classify pre-reflow.
const innerTextR = maybeReflow(innerText, o.reflow);
if (innerTextR.length < o.minToolResultChars) {
bumpPassthrough(info, 'below_threshold');
newInner.push(ib as TextBlock | ImageBlock);
continue;
}
if (!isCompressionProfitable(innerText, o.cols, o.maxImagesPerToolResult, numCols, o.charsPerToken)) {
if (!isCompressionProfitable(innerTextR, o.cols, o.maxImagesPerToolResult, numCols, o.charsPerToken)) {
bumpPassthrough(info, 'not_profitable');
newInner.push(ib as TextBlock | ImageBlock);
continue;
}
const paged = truncateForBudget(innerText, o.maxImagesPerToolResult, o.cols, numCols);
const paged = truncateForBudget(innerTextR, o.maxImagesPerToolResult, o.cols, numCols);
if (paged.truncated) {
info.truncatedToolResults = (info.truncatedToolResults ?? 0) + 1;
info.omittedChars = (info.omittedChars ?? 0) + paged.omittedChars;
+32 -32
View File
File diff suppressed because one or more lines are too long
@@ -8,7 +8,6 @@
import { numFmt } from '../lib/format.js';
$: rows = $recent.data?.recent ?? [];
$: imageIds = $recent.data?.image_ids ?? [];
function statusCls(status: number): string {
if (status >= 500) return 'bad';
@@ -26,7 +25,7 @@
<thead>
<tr>
<th>#</th>
<th>status</th>
<th class="num">status</th>
<th>path</th>
<th class="num">cc</th>
<th class="num">cr</th>
@@ -43,6 +42,7 @@
<td class="num {statusCls(e.status)}">{e.status}</td>
<td class="small">{shortPath(e.path)}</td>
<td class="num">{e.cc_added ? '✓' : '-'}</td>
<td class="num">{e.cache_read != null ? numFmt(e.cache_read) : '-'}</td>
<td class="num">{e.baseline_input != null ? numFmt(e.baseline_input) : '-'}</td>
<td class="num">{e.actual_input != null ? numFmt(e.actual_input) : '-'}</td>
<td class="num pos">
@@ -52,11 +52,7 @@
</td>
<td class="num">
{#if e.img_id != null}
{#if imageIds.includes(e.img_id)}
<button class="view-btn" on:click={() => selectedImageId.set(e.img_id ?? null)}>view</button>
{:else}
<span class="muted">-</span>
{/if}
<button class="view-btn" on:click={() => selectedImageId.set(e.img_id ?? null)}>view</button>
{:else}
<span class="muted">-</span>
{/if}