mirror of
https://github.com/teamchong/pxpipe.git
synced 2026-07-22 02:02:51 +02:00
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:
@@ -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
@@ -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
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}
|
||||
|
||||
Reference in New Issue
Block a user