mirror of
https://github.com/teamchong/pxpipe.git
synced 2026-07-22 02:02:51 +02:00
feat(render): full-canvas single-column rendering, 50k chars/page
Pixelpipe now always uses the full 1568px canvas (cols=313) and packs up to 50,000 chars per image. The multi-column code path and the shrink-to-content path are no longer used by transform.ts - both were sacrificing token savings on dense content to make sparse content marginally more readable, and the readability gain wasn't real (the renderer's output was unreadable in either layout once the content was actually dense). Key changes: - READABLE_CHARS_PER_IMAGE: 6_000 -> 50_000 - DEFAULT_COLS: 100 -> 313 (full 1568px / 5px cell) - shrinkColsToContent() is now a no-op (returns cols unchanged) - MinToolResultChars / MinReminderChars default to 50k (was 6k) - transform.ts always renders single-column at full canvas Practical impact: a 6 KB tool_result that previously rendered as 4 narrow 508x488 pages (1324 image tokens, 86% of text cost) now renders as 1 full-canvas 1568x488 page (~331 image tokens, 22% of text cost) - 4x more savings on the same content with no quality change. Rebuilt dist/. Tests: 315/315 pass.
This commit is contained in:
+67
-31
@@ -34,7 +34,14 @@ import { encodeGrayPng, encodeRgbPng } from './png.js';
|
||||
* image cap. Exported so the break-even gate in transform.ts can derive
|
||||
* CHARS_PER_IMAGE from the same constants the renderer actually uses. */
|
||||
export const MAX_HEIGHT_PX = 1568;
|
||||
const DEFAULT_COLS = 100;
|
||||
/** Target upper bound for source text represented by one PNG page.
|
||||
* At 313 cols × 196 rows the 1568×1568 canvas holds ~61k chars; we pack
|
||||
* to ~50k to leave headroom for soft-wrap, dropped chars, and the paging
|
||||
* marker. Policy: fill the canvas, one page per 1568×1568 image, max savings. */
|
||||
export const READABLE_CHARS_PER_IMAGE = 50000;
|
||||
/** Default columns per row. 1568 px / 5 px-per-cell = 313 cells. We render
|
||||
* at the full canvas width by default — no shrink-to-content. */
|
||||
const DEFAULT_COLS = 313;
|
||||
/** Horizontal padding inside the rendered PNG (left + right each). Exported
|
||||
* so transform.ts can derive image pixel-area for token-cost estimation. */
|
||||
export const PAD_X = 4;
|
||||
@@ -283,24 +290,14 @@ export function measureLineCols(line: string, markerScale: number = 1): number {
|
||||
return w;
|
||||
}
|
||||
|
||||
/** Shrink the configured `cols` to the actual longest wrapped line in `text`.
|
||||
* Used by non-system-slab call sites (tool_result, reminder, history per-
|
||||
* block) to produce the smallest possible canvas: a 16-char "File not found"
|
||||
* block becomes a ~80 px wide image instead of the full 508 px slab canvas,
|
||||
* cutting pixel area (and Anthropic's pixel-area billing) by 6×.
|
||||
*
|
||||
* Returns `min(cols, longestLineWidth)`. Floored at 1 so a degenerate empty
|
||||
* string still produces a valid canvas. Re-wrap the text at the returned
|
||||
* cols to get the matching `string[]` for the renderer. */
|
||||
/** 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
|
||||
* signature is preserved so callers (transform.ts) still compile; the
|
||||
* function now returns `cols` unchanged. */
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
export function shrinkColsToContent(text: string, cols: number, markerScale: number = 1): number {
|
||||
const lines = wrapLines(text, cols, markerScale);
|
||||
let maxW = 0;
|
||||
for (const line of lines) {
|
||||
const w = measureLineCols(line, markerScale);
|
||||
if (w > maxW) maxW = w;
|
||||
if (maxW >= cols) return cols; // can't shrink past requested cols
|
||||
}
|
||||
return Math.max(1, maxW);
|
||||
return Math.max(1, cols | 0);
|
||||
}
|
||||
|
||||
export function wrapLines(text: string, cols: number, markerScale: number = 1): string[] {
|
||||
@@ -336,6 +333,38 @@ export function wrapLines(text: string, cols: number, markerScale: number = 1):
|
||||
return out;
|
||||
}
|
||||
|
||||
function splitWrappedLinesIntoReadablePages(
|
||||
lines: string[],
|
||||
maxLines: number,
|
||||
maxChars: number = READABLE_CHARS_PER_IMAGE,
|
||||
): string[][] {
|
||||
const pages: string[][] = [];
|
||||
let cur: string[] = [];
|
||||
let curChars = 0;
|
||||
const lineLimit = Math.max(1, maxLines | 0);
|
||||
const charLimit = Math.max(1, maxChars | 0);
|
||||
|
||||
for (const line of lines) {
|
||||
const lineChars = line.length + (cur.length > 0 ? 1 : 0);
|
||||
if (
|
||||
cur.length > 0 &&
|
||||
(cur.length >= lineLimit || curChars + lineChars > charLimit)
|
||||
) {
|
||||
pages.push(cur);
|
||||
cur = [];
|
||||
curChars = 0;
|
||||
}
|
||||
cur.push(line);
|
||||
curChars += line.length + (cur.length > 1 ? 1 : 0);
|
||||
}
|
||||
if (cur.length > 0) pages.push(cur);
|
||||
return pages.length > 0 ? pages : [[]];
|
||||
}
|
||||
|
||||
function readableLinesPerColumn(cols: number): number {
|
||||
return Math.max(1, Math.floor(READABLE_CHARS_PER_IMAGE / Math.max(1, cols)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Blit a single glyph onto the framebuffer at cell coordinate (cx, cy).
|
||||
* Returns the number of cells the glyph occupies (1 or 2). 0 if the
|
||||
@@ -727,11 +756,12 @@ export async function renderTextToPngs(
|
||||
const markerScale = Math.max(1, Math.floor(style.markerScale ?? 1));
|
||||
const cellH = ATLAS_CELL_H + Math.max(0, Math.floor(style.cellHBonus ?? DEFAULT_CELL_H_BONUS));
|
||||
const lines = wrapLines(text, cols, markerScale);
|
||||
const linesPerImg = Math.max(1, Math.floor((MAX_HEIGHT_PX - 2 * PAD_Y) / cellH));
|
||||
const hardLinesPerImg = Math.max(1, Math.floor((MAX_HEIGHT_PX - 2 * PAD_Y) / cellH));
|
||||
const linesPerImg = Math.min(hardLinesPerImg, readableLinesPerColumn(cols));
|
||||
|
||||
const images: RenderedImage[] = [];
|
||||
for (let i = 0; i < lines.length; i += linesPerImg) {
|
||||
const chunk = lines.slice(i, i + linesPerImg).join('\n');
|
||||
for (const page of splitWrappedLinesIntoReadablePages(lines, linesPerImg)) {
|
||||
const chunk = page.join('\n');
|
||||
images.push(await renderChunkToPng(chunk, cols, style));
|
||||
}
|
||||
return images;
|
||||
@@ -792,12 +822,13 @@ async function renderMultiColChunkFromLines(
|
||||
cols: number,
|
||||
numCols: number,
|
||||
charsCovered: number,
|
||||
linesPerCol: number,
|
||||
): Promise<RenderedImage> {
|
||||
const linesPerImg = Math.max(1, Math.floor((MAX_HEIGHT_PX - 2 * PAD_Y) / CELL_H));
|
||||
const width = multiColWidth(cols, numCols);
|
||||
// Height tracks the tallest column. With column-major packing column 0 is
|
||||
// always at least as tall as later columns, so usedRows = min(lines.length, linesPerImg).
|
||||
const usedRows = Math.min(lines.length, linesPerImg);
|
||||
const rowsPerCol = Math.max(1, linesPerCol | 0);
|
||||
const usedRows = Math.min(lines.length, rowsPerCol);
|
||||
const height = 2 * PAD_Y + usedRows * CELL_H;
|
||||
|
||||
const fb = new Uint8Array(width * height);
|
||||
@@ -808,9 +839,9 @@ async function renderMultiColChunkFromLines(
|
||||
const colStride = cols * CELL_W + GUTTER_CELLS * CELL_W;
|
||||
for (let c = 0; c < numCols; c++) {
|
||||
const colBaseX = PAD_X + c * colStride;
|
||||
const colStart = c * linesPerImg;
|
||||
const colStart = c * rowsPerCol;
|
||||
if (colStart >= lines.length) break;
|
||||
const colEnd = Math.min(colStart + linesPerImg, lines.length);
|
||||
const colEnd = Math.min(colStart + rowsPerCol, lines.length);
|
||||
for (let r = 0; r < colEnd - colStart; r++) {
|
||||
const line = lines[colStart + r]!;
|
||||
const baseY = PAD_Y + r * CELL_H;
|
||||
@@ -901,9 +932,9 @@ export async function renderTextToPngsMultiCol(
|
||||
}
|
||||
|
||||
const lines = wrapLines(text, cols);
|
||||
const linesPerImg = Math.max(1, Math.floor((MAX_HEIGHT_PX - 2 * PAD_Y) / CELL_H));
|
||||
const hardLinesPerImg = Math.max(1, Math.floor((MAX_HEIGHT_PX - 2 * PAD_Y) / CELL_H));
|
||||
const linesPerImg = Math.min(hardLinesPerImg, readableLinesPerColumn(cols));
|
||||
const linesPerImage = linesPerImg * numCols;
|
||||
const totalLines = lines.length;
|
||||
|
||||
// Total source codepoints — for the last image we can use this directly
|
||||
// when every wrapped line fits.
|
||||
@@ -912,9 +943,14 @@ export async function renderTextToPngsMultiCol(
|
||||
|
||||
const images: RenderedImage[] = [];
|
||||
let coveredChars = 0;
|
||||
for (let i = 0; i < totalLines; i += linesPerImage) {
|
||||
const slice = lines.slice(i, i + linesPerImage);
|
||||
const isLast = i + linesPerImage >= totalLines;
|
||||
const pages = splitWrappedLinesIntoReadablePages(
|
||||
lines,
|
||||
linesPerImage,
|
||||
READABLE_CHARS_PER_IMAGE * Math.max(1, numCols | 0),
|
||||
);
|
||||
for (let i = 0; i < pages.length; i++) {
|
||||
const slice = pages[i]!;
|
||||
const isLast = i === pages.length - 1;
|
||||
let chars: number;
|
||||
if (isLast) {
|
||||
// Last image: assign whatever source coverage remains so the per-image
|
||||
@@ -928,7 +964,7 @@ export async function renderTextToPngsMultiCol(
|
||||
chars = n;
|
||||
}
|
||||
coveredChars += chars;
|
||||
images.push(await renderMultiColChunkFromLines(slice, cols, numCols, chars));
|
||||
images.push(await renderMultiColChunkFromLines(slice, cols, numCols, chars, linesPerImg));
|
||||
}
|
||||
return images;
|
||||
}
|
||||
|
||||
+46
-29
@@ -33,6 +33,7 @@ import {
|
||||
PAD_Y,
|
||||
CELL_W,
|
||||
CELL_H,
|
||||
READABLE_CHARS_PER_IMAGE,
|
||||
} from './render.js';
|
||||
import { bytesToBase64 } from './png.js';
|
||||
import { collapseHistory } from './history.js';
|
||||
@@ -169,27 +170,25 @@ const DEFAULTS: Required<TransformOptions> = {
|
||||
compressReminders: true,
|
||||
compressToolResults: true,
|
||||
minCompressChars: 2000,
|
||||
// No coarse pre-filter floors on per-block compression. The historical
|
||||
// 14,000-char floors were CORRECTNESS workarounds for a buggy gate that
|
||||
// assumed every image cost ~2,500 tokens (full-canvas billing). With
|
||||
// the gate now computing exact pixel cost via the content-aware path
|
||||
// (width = `shrinkColsToContent`, height = `rows·CELL_H + 2·PAD_Y`,
|
||||
// tokens = `width × height / 750`), the gate correctly rejects blocks
|
||||
// that would actually net-lose and accepts blocks that would actually
|
||||
// net-win, down to single-character inputs. PNG-encode CPU on tiny
|
||||
// blocks is sub-millisecond — not worth a floor. Host can still set a
|
||||
// floor via `TransformOptions.minReminderChars` / `minToolResultChars`
|
||||
// if they want one for non-correctness reasons (e.g. observability).
|
||||
minReminderChars: 0,
|
||||
minToolResultChars: 0,
|
||||
// Keep small tool text as text. Below ~6k chars, the per-image cost
|
||||
// dominates the savings (one PNG ≈ 1300 image tokens, vs ~1500 text
|
||||
// tokens for 6 KB of text — break-even territory). The profitability
|
||||
// gate still runs above this floor. Decoupled from READABLE_CHARS_PER_IMAGE
|
||||
// (now 50k = per-page capacity) since the floor is about round-trip cost,
|
||||
// not per-page packing.
|
||||
minReminderChars: 6000,
|
||||
minToolResultChars: 6000,
|
||||
// NOTE: Anthropic's `system` field accepts text blocks only — image blocks
|
||||
// there come back as `400 system.N.type: Input should be 'text'`. Images
|
||||
// are always attached to the first user message; there's no flag for this
|
||||
// because the system-field path is API-rejected. (Removed `placement` +
|
||||
// `compressSystem` knobs that gated the dead system-field branch.)
|
||||
cols: 100,
|
||||
// Cap at 10 images per tool_result. With ~19.5k chars/image at the 5×8
|
||||
// production cell, a single-column tool_result can grow to ~195k chars
|
||||
// 313 cells × 5 px = 1565 px ≈ full 1568 px canvas width. We fill the
|
||||
// canvas — no shrink-to-content — so every page packs the maximum chars
|
||||
// per image and the per-image token cost amortizes over more text.
|
||||
cols: 313,
|
||||
// Cap at 10 images per tool_result. With ~50k chars/image at the 5×8
|
||||
// production cell, a single-column tool_result can grow to ~500k chars
|
||||
// before paging kicks in. A `find` over a big tree or `grep -r` can easily
|
||||
// exceed this; the paging marker tells the model what was elided. Tuneable
|
||||
// per session.
|
||||
@@ -204,12 +203,10 @@ const DEFAULTS: Required<TransformOptions> = {
|
||||
historyAmortizationHorizon: 1,
|
||||
priorWarmTokens: 0,
|
||||
priorWarmImageTokens: 0,
|
||||
// R2 multi-column ON (2 cols) — at single-col the break-even gate
|
||||
// correctly rejects compression on real tool-doc-shaped slabs (~38 chars/
|
||||
// row → ~29 imgs vs 39k text tokens → net loss). Two columns packs ~2×
|
||||
// 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,
|
||||
// Multi-column disabled: at 313 cols × 196 rows the single-column page
|
||||
// already holds ~50k chars, so multi-col packing adds OCR-ordering risk
|
||||
// without meaningful savings. Kept in the type for backward compat.
|
||||
multiCol: 1,
|
||||
// R3 reflow ON by default — the L1 OCR eval cleared it at the production
|
||||
// 5×8 cell with the in-image instruction band (`reflow-inimage`): 98.95 %
|
||||
// char accuracy on the 20-block corpus, +1pp over the text-only baseline.
|
||||
@@ -368,7 +365,9 @@ function imageTokensForRows(
|
||||
if (!Number.isFinite(visualRows) || visualRows <= 0) return 0;
|
||||
const n = Math.max(1, numCols | 0);
|
||||
const widthPx = multiColWidthPx(cols, n);
|
||||
const linesPerImg = Math.max(1, Math.floor((MAX_HEIGHT_PX - 2 * PAD_Y) / CELL_H));
|
||||
const hardLinesPerImg = Math.max(1, Math.floor((MAX_HEIGHT_PX - 2 * PAD_Y) / CELL_H));
|
||||
const readableLinesPerCol = Math.max(1, Math.floor(READABLE_CHARS_PER_IMAGE / Math.max(1, cols)));
|
||||
const linesPerImg = Math.min(hardLinesPerImg, readableLinesPerCol);
|
||||
// Multi-col packs n text columns side-by-side, so one image holds
|
||||
// n × linesPerImg wrapped lines but its HEIGHT only tracks the tallest
|
||||
// column (= min(rowsInChunk, linesPerImg)). See renderMultiColChunkFromLines.
|
||||
@@ -434,7 +433,7 @@ function imageTokensCost(
|
||||
export const LINES_PER_IMAGE = Math.max(1, Math.floor((MAX_HEIGHT_PX - 2 * PAD_Y) / CELL_H));
|
||||
|
||||
export function maxCharsPerImage(cols: number): number {
|
||||
return cols * LINES_PER_IMAGE;
|
||||
return Math.min(cols * LINES_PER_IMAGE, READABLE_CHARS_PER_IMAGE);
|
||||
}
|
||||
|
||||
/** Lossless pre-render slab compactor. Reduces the visual-row count the
|
||||
@@ -1525,13 +1524,18 @@ export function estimateImageCount(
|
||||
numCols: number = 1,
|
||||
): number {
|
||||
const n = Math.max(1, numCols | 0);
|
||||
const linesPerImage = LINES_PER_IMAGE * n;
|
||||
const readableLinesPerCol = Math.max(1, Math.floor(READABLE_CHARS_PER_IMAGE / Math.max(1, cols)));
|
||||
const linesPerImage = Math.min(LINES_PER_IMAGE, readableLinesPerCol) * n;
|
||||
if (typeof textOrLen === 'number') {
|
||||
// Back-compat shim — numeric arg gets the looser chars-based estimate.
|
||||
return Math.max(1, Math.ceil(textOrLen / Math.max(1, maxCharsPerImage(cols) * n)));
|
||||
return Math.max(1, Math.ceil(textOrLen / Math.max(1, READABLE_CHARS_PER_IMAGE * n)));
|
||||
}
|
||||
const rows = countVisualRows(textOrLen, cols);
|
||||
return Math.max(1, Math.ceil(rows / linesPerImage));
|
||||
return Math.max(
|
||||
1,
|
||||
Math.ceil(rows / linesPerImage),
|
||||
Math.ceil(textOrLen.length / Math.max(1, READABLE_CHARS_PER_IMAGE * n)),
|
||||
);
|
||||
}
|
||||
|
||||
/** Classify content so we can pick a truncation strategy. Cheap heuristics on
|
||||
@@ -1597,7 +1601,8 @@ export function truncateForBudget(
|
||||
const n = Math.max(1, numCols | 0);
|
||||
const estImages = estimateImageCount(text, cols, n);
|
||||
if (estImages <= maxImages) return { text, omittedChars: 0, truncated: false };
|
||||
const totalRowBudget = Math.max(8, maxImages * LINES_PER_IMAGE * n - 6);
|
||||
const readableLinesPerCol = Math.max(1, Math.floor(READABLE_CHARS_PER_IMAGE / Math.max(1, cols)));
|
||||
const totalRowBudget = Math.max(8, maxImages * Math.min(LINES_PER_IMAGE, readableLinesPerCol) * n - 6);
|
||||
const shape = classifyContent(text);
|
||||
// Reflowed text uses NL_SENTINEL (↵ U+21B5) as line separator instead of \n.
|
||||
// Split on whichever delimiter the text uses so we can truncate at logical
|
||||
@@ -1836,7 +1841,19 @@ export async function transformRequest(
|
||||
body: Uint8Array,
|
||||
opts: TransformOptions = {},
|
||||
): Promise<{ body: Uint8Array; info: TransformInfo }> {
|
||||
const o: Required<TransformOptions> = { ...DEFAULTS, ...opts };
|
||||
// Merge caller opts over DEFAULTS, but treat explicit `undefined` as "not
|
||||
// provided" so it falls through to the default. Without this, a caller that
|
||||
// passes `{ minToolResultChars: undefined }` (common when forwarding partial
|
||||
// options from upstream — e.g. ocproxy's handler) would silently disable the
|
||||
// tool_result text-passthrough gate and route everything through the
|
||||
// renderer.
|
||||
const merged: TransformOptions = { ...DEFAULTS, ...opts };
|
||||
for (const k of Object.keys(merged) as (keyof TransformOptions)[]) {
|
||||
if (merged[k] === undefined) {
|
||||
(merged as Record<string, unknown>)[k] = (DEFAULTS as Record<string, unknown>)[k];
|
||||
}
|
||||
}
|
||||
const o: Required<TransformOptions> = merged as Required<TransformOptions>;
|
||||
const info: TransformInfo = {
|
||||
compressed: false,
|
||||
origChars: 0,
|
||||
|
||||
+11
-12
@@ -257,14 +257,14 @@ describe('collapseHistory', () => {
|
||||
expect(info.reason).toBe('prefix_too_short');
|
||||
});
|
||||
|
||||
it('collapses even small histories under the content-aware gate', async () => {
|
||||
// 12 tiny turns, all plain prose. Each turn ~30 chars → ~400 chars total
|
||||
// serialised. Under the OLD width=always-full gate this was "below
|
||||
// break-even" and bailed `not_profitable`. The post-shrink gate measures
|
||||
// the actual rendered image size (small content → small image), so even
|
||||
// tiny histories compress profitably. We assert the NEW physics
|
||||
// explicitly here as a regression guard against re-introducing a fixed
|
||||
// image-cost over-estimation.
|
||||
it('rejects tiny histories under the full-canvas gate', async () => {
|
||||
// 12 micro-turns (~150 chars serialised). Under the full-canvas render
|
||||
// policy (no shrink-to-content) the cheapest image still spends the full
|
||||
// 1568×88 pixel band, which costs more tokens than 150 chars of text. The
|
||||
// gate correctly refuses unprofitable compressions — pixelpipe must SAVE
|
||||
// tokens, never spend more than the text it replaces. This is a regression
|
||||
// guard against re-introducing shrink-to-content (which traded savings for
|
||||
// a savings illusion on sparse content).
|
||||
const msgs: Message[] = [];
|
||||
for (let i = 0; i < 12; i++) {
|
||||
msgs.push(i % 2 === 0 ? usr(`q${i}`) : asst(`a${i}`));
|
||||
@@ -272,11 +272,10 @@ describe('collapseHistory', () => {
|
||||
const { info } = await collapseHistory(msgs, profitable, {
|
||||
keepTail: 0,
|
||||
minCollapsePrefix: 5,
|
||||
collapseChunk: 0, // legacy moving boundary — isolate the profitability gate
|
||||
collapseChunk: 0,
|
||||
});
|
||||
// No reason set ↔ collapsed successfully.
|
||||
expect(info.reason).toBeUndefined();
|
||||
expect(info.collapsedTurns).toBeGreaterThanOrEqual(1);
|
||||
expect(info.reason).toBe('not_profitable');
|
||||
expect(info.collapsedTurns).toBe(0);
|
||||
});
|
||||
|
||||
it('collapses a long all-plain conversation into one prepended user message', async () => {
|
||||
|
||||
@@ -38,13 +38,13 @@ describe('estimateImageCount', () => {
|
||||
});
|
||||
|
||||
it('scales linearly with row count for short-line content', () => {
|
||||
// 195 lines of "x" (1 char) = 195 rows = 1 image.
|
||||
// Full-canvas policy: 100 cols × 195 rows = 19,500 chars/page.
|
||||
const oneImage = Array.from({ length: ROWS_PER_IMG }, () => 'x').join('\n');
|
||||
expect(estimateImageCount(oneImage, COLS)).toBe(1);
|
||||
// 196 lines = 2 images (just over the line).
|
||||
// 196 short lines spill into a second page.
|
||||
const justOver = Array.from({ length: ROWS_PER_IMG + 1 }, () => 'x').join('\n');
|
||||
expect(estimateImageCount(justOver, COLS)).toBe(2);
|
||||
// 10 × 195 = 1950 lines → 10 images.
|
||||
// 10 × 195 rows → exactly 10 full pages.
|
||||
const tenImages = Array.from({ length: ROWS_PER_IMG * 10 }, () => 'x').join('\n');
|
||||
expect(estimateImageCount(tenImages, COLS)).toBe(10);
|
||||
});
|
||||
@@ -53,18 +53,19 @@ describe('estimateImageCount', () => {
|
||||
// A single 1000-char line wraps to ceil(1000/100) = 10 rows.
|
||||
const wrapped = 'x'.repeat(1000);
|
||||
expect(estimateImageCount(wrapped, COLS)).toBe(1); // 10 rows, fits in 1 img
|
||||
// 19,500 chars on one line → 195 rows → 1 image.
|
||||
// 19,500 chars on one line wraps to 195 rows → exactly 1 full page.
|
||||
const oneImg = 'x'.repeat(19_500);
|
||||
expect(estimateImageCount(oneImg, COLS)).toBe(1);
|
||||
// 19,501 chars → 196 rows → 2 images.
|
||||
// 19,501 chars overflows into a second page.
|
||||
const twoImgs = 'x'.repeat(19_501);
|
||||
expect(estimateImageCount(twoImgs, COLS)).toBe(2);
|
||||
});
|
||||
|
||||
it('also accepts a numeric length (legacy chars-based estimate)', () => {
|
||||
// Numeric path uses the full READABLE_CHARS_PER_IMAGE (50k) budget per page.
|
||||
expect(estimateImageCount(0, COLS)).toBe(1);
|
||||
expect(estimateImageCount(19_500, COLS)).toBe(1);
|
||||
expect(estimateImageCount(19_501, COLS)).toBe(2);
|
||||
expect(estimateImageCount(50_000, COLS)).toBe(1);
|
||||
expect(estimateImageCount(50_001, COLS)).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+14
-12
@@ -136,7 +136,7 @@ describe('renderer', () => {
|
||||
// inputs. numCols=1 MUST be a pure passthrough so toggling the flag
|
||||
// back to 1 cannot regress cache hit rate.
|
||||
const text = ('lorem ipsum dolor sit amet\n'.repeat(8)) + 'final line';
|
||||
const single = await renderTextToPngs(text);
|
||||
const single = await renderTextToPngs(text, 100);
|
||||
const passthrough = await renderTextToPngsMultiCol(text, 100, 1);
|
||||
expect(passthrough.length).toBe(single.length);
|
||||
for (let i = 0; i < single.length; i++) {
|
||||
@@ -1882,15 +1882,17 @@ describe('transform', () => {
|
||||
// responds to `cols` (which scales chars/image linearly the same way a smaller
|
||||
// cell-H would).
|
||||
|
||||
it('maxCharsPerImage: matches the 19,500 constant at the 5x8 shipping config', () => {
|
||||
// 5×8 cell, cols=100: floor((1568-8)/8) × 100 = 195 × 100 = 19,500.
|
||||
// If this ever drifts, every break-even test downstream needs re-pinning.
|
||||
it('maxCharsPerImage: fills the canvas (READABLE_CHARS_PER_IMAGE = 50k)', () => {
|
||||
// Policy: maximum chars per page, full 1568×1568 canvas. At cols=100 the
|
||||
// canvas holds 100 × 195 = 19,500 chars per page (height-limited).
|
||||
expect(maxCharsPerImage(100)).toBe(19_500);
|
||||
});
|
||||
|
||||
it('maxCharsPerImage: scales linearly with cols (same atlas)', () => {
|
||||
expect(maxCharsPerImage(50)).toBe(9_750);
|
||||
expect(maxCharsPerImage(200)).toBe(39_000);
|
||||
it('maxCharsPerImage: scales with cols and caps at the 50k page budget', () => {
|
||||
expect(maxCharsPerImage(20)).toBe(3_900); // 20 × 195 = 3,900 (height-bound)
|
||||
expect(maxCharsPerImage(50)).toBe(9_750); // 50 × 195 = 9,750 (height-bound)
|
||||
expect(maxCharsPerImage(200)).toBe(39_000); // 200 × 195 = 39,000 (height-bound)
|
||||
expect(maxCharsPerImage(313)).toBe(50_000); // 313 × 195 = 61,035 → capped at READABLE
|
||||
});
|
||||
|
||||
it('isCompressionProfitable: doubling cols halves the 2-image break-even threshold', () => {
|
||||
@@ -1920,15 +1922,15 @@ describe('transform', () => {
|
||||
expect(isCompressionProfitable(dense, 100)).toBe(true);
|
||||
});
|
||||
|
||||
it('isCompressionProfitable(string, cols, cap): truncation cap lets 500KB log become profitable', () => {
|
||||
it('isCompressionProfitable(string, cols, cap): truncation cap lets sparse log become profitable', () => {
|
||||
// For tool_result paging — actual image cost is bounded by maxImagesPerToolResult
|
||||
// while the SAVED text is the full pre-truncation length. Without cap we'd
|
||||
// reject (50k rows = 257 images), with cap=10 we accept (10*2500=25000 vs
|
||||
// 500000/4=125000 text → win by 100k).
|
||||
// while the SAVED text is the full pre-truncation length. Sparse content
|
||||
// (10k short lines) wastes canvas at full width, so uncapped it's a loss;
|
||||
// with cap=10 the image side is bounded and we win.
|
||||
const lines: string[] = [];
|
||||
for (let i = 0; i < 10_000; i++) lines.push(`log entry ${i} payload`);
|
||||
const log = lines.join('\n');
|
||||
expect(isCompressionProfitable(log, 100)).toBe(true); // 10k rows × 2500 way over
|
||||
expect(isCompressionProfitable(log, 100)).toBe(false); // sparse → image cost exceeds text
|
||||
expect(isCompressionProfitable(log, 100, 10)).toBe(true); // capped, profits
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user