mirror of
https://github.com/teamchong/pxpipe.git
synced 2026-07-22 02:02:51 +02:00
transform: row-aware break-even gate stops net-loss compressions
isCompressionProfitable previously estimated image count as
ceil(chars / charsPerImage), which assumes uniform full-width line-fill.
renderTextToPngs actually budgets by wrapped visual rows (141 per image),
so newline-heavy code/logs and short-line content render to many more
images than the chars-based estimate predicts.
Live impact: dashboard reported -69% reduction (net loss). 33 compressions
averaged 5494 chars/image (39% fill ratio) — gate under-counted cost by
~9× on sparse content and let net-losing compressions through.
Fix:
- isCompressionProfitable now accepts the full text string; uses
estimateImageCount (the same row-aware math renderTextToPngs uses).
- Adds an optional imageCountCap for paths that truncate before rendering
(tool_results). Cost bounded by cap; savings still measured against
the full pre-truncation length, so 500KB logs still profit.
- All 4 production call sites in transform.ts pass the string. The
tool_result sites also pass maxImagesPerToolResult as the cap.
- Number-arg form retained for back-compat with the existing
isCompressionProfitable(N) unit tests.
Tests:
- 3 new unit tests pin the row-aware regression: sparse content rejected,
dense content accepted, capped content (paging) accepted.
- 7 integration tests previously used 'claude.md\n'.repeat(5000) — sparse
fixtures the new gate correctly rejects. Updated to dense fixtures
('x'.repeat(40000) or single-line repeats without embedded newlines)
so they still exercise the cache_control / determinism / reminder /
tool_result code paths they're meant to.
This commit is contained in:
+45
-11
@@ -153,17 +153,48 @@ export function maxCharsPerImage(cols: number): number {
|
|||||||
return cols * LINES_PER_IMAGE;
|
return cols * LINES_PER_IMAGE;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Returns true iff image-compressing a text block of `textLen` chars would
|
/** Returns true iff image-compressing a text block would actually save tokens
|
||||||
* actually save tokens vs leaving it as text. Used as the gate before every
|
* vs leaving it as text. Used as the gate before every image-encoding
|
||||||
* image-encoding decision in transformRequest.
|
* decision in transformRequest.
|
||||||
|
*
|
||||||
|
* Pass the **actual text string** when possible — the function will
|
||||||
|
* soft-wrap-count visual rows to match what `renderTextToPngs` will
|
||||||
|
* actually produce. Newline-heavy content (low fill ratio) renders to
|
||||||
|
* *more* images than the naive `chars / charsPerImage` estimate, and
|
||||||
|
* using the looser estimate lets net-losing compressions through.
|
||||||
|
*
|
||||||
|
* Passing a `number` falls back to the looser chars-only estimate for
|
||||||
|
* back-compat with existing unit tests; production transform call sites
|
||||||
|
* should always pass the string.
|
||||||
*
|
*
|
||||||
* `cols` defaults to `DEFAULTS.cols` (100) so existing callers and unit
|
* `cols` defaults to `DEFAULTS.cols` (100) so existing callers and unit
|
||||||
* tests that pass only `textLen` keep working byte-identically at the
|
* tests that pass only `textLen` keep working byte-identically at the
|
||||||
* current atlas. New call sites should pass `o.cols` so a runtime
|
* current atlas. New call sites should pass `o.cols` so a runtime
|
||||||
* `--cols` override flows into the break-even math too. */
|
* `--cols` override flows into the break-even math too. */
|
||||||
export function isCompressionProfitable(textLen: number, cols: number = DEFAULTS.cols): boolean {
|
export function isCompressionProfitable(
|
||||||
const charsPerImage = maxCharsPerImage(cols);
|
textOrLen: string | number,
|
||||||
const estImages = Math.max(1, Math.ceil(textLen / charsPerImage));
|
cols: number = DEFAULTS.cols,
|
||||||
|
imageCountCap?: number,
|
||||||
|
): boolean {
|
||||||
|
let estImages: number;
|
||||||
|
let textLen: number;
|
||||||
|
if (typeof textOrLen === 'string') {
|
||||||
|
// Row-aware: matches renderTextToPngs() image budgeting exactly.
|
||||||
|
estImages = estimateImageCount(textOrLen, cols);
|
||||||
|
textLen = textOrLen.length;
|
||||||
|
} else {
|
||||||
|
// Looser chars-only estimate. Assumes lines fill width — wrong for
|
||||||
|
// newline-heavy code/logs but kept for back-compat.
|
||||||
|
const charsPerImage = maxCharsPerImage(cols);
|
||||||
|
estImages = Math.max(1, Math.ceil(textOrLen / charsPerImage));
|
||||||
|
textLen = textOrLen;
|
||||||
|
}
|
||||||
|
// For code paths that truncate before rendering (tool_results), the
|
||||||
|
// actual image cost is bounded by the cap — text savings are still
|
||||||
|
// measured against the full pre-truncation length.
|
||||||
|
if (imageCountCap !== undefined && imageCountCap > 0) {
|
||||||
|
estImages = Math.min(estImages, imageCountCap);
|
||||||
|
}
|
||||||
const imageTokensCost = estImages * TOKENS_PER_IMAGE;
|
const imageTokensCost = estImages * TOKENS_PER_IMAGE;
|
||||||
const textTokensEquivalent = textLen / CHARS_PER_TOKEN;
|
const textTokensEquivalent = textLen / CHARS_PER_TOKEN;
|
||||||
return imageTokensCost < textTokensEquivalent;
|
return imageTokensCost < textTokensEquivalent;
|
||||||
@@ -1116,7 +1147,10 @@ export async function transformRequest(
|
|||||||
// usually 25-30 KB so it always passes (1 image @ 2500 tokens < 25000/4 =
|
// usually 25-30 KB so it always passes (1 image @ 2500 tokens < 25000/4 =
|
||||||
// 6250 text-equivalent tokens), but the check guards against the edge
|
// 6250 text-equivalent tokens), but the check guards against the edge
|
||||||
// case where a tiny tool docs + tiny static slab combine to <10k chars.
|
// case where a tiny tool docs + tiny static slab combine to <10k chars.
|
||||||
if (!isCompressionProfitable(combined.length, o.cols)) {
|
// Pass the full text so the gate uses row-aware image-count math (matches
|
||||||
|
// renderTextToPngs exactly — newline-heavy content renders to more images
|
||||||
|
// than the naive chars/charsPerImage estimate).
|
||||||
|
if (!isCompressionProfitable(combined, o.cols)) {
|
||||||
info.reason = `not_profitable (slab=${combined.length} chars)`;
|
info.reason = `not_profitable (slab=${combined.length} chars)`;
|
||||||
bumpPassthrough(info, 'not_profitable');
|
bumpPassthrough(info, 'not_profitable');
|
||||||
return { body, info };
|
return { body, info };
|
||||||
@@ -1212,13 +1246,13 @@ export async function transformRequest(
|
|||||||
processedExisting.push(blk);
|
processedExisting.push(blk);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!isCompressionProfitable(textLen, o.cols)) {
|
const reminderText = (blk as TextBlock).text;
|
||||||
|
if (!isCompressionProfitable(reminderText, o.cols)) {
|
||||||
// Above threshold but image cost ≥ text cost. Net loss to compress.
|
// Above threshold but image cost ≥ text cost. Net loss to compress.
|
||||||
bumpPassthrough(info, 'not_profitable');
|
bumpPassthrough(info, 'not_profitable');
|
||||||
processedExisting.push(blk);
|
processedExisting.push(blk);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const reminderText = (blk as TextBlock).text;
|
|
||||||
const { blocks: imgs, droppedChars, droppedCodepoints: dcp } =
|
const { blocks: imgs, droppedChars, droppedCodepoints: dcp } =
|
||||||
await textToImageBlocks(reminderText, o.cols);
|
await textToImageBlocks(reminderText, o.cols);
|
||||||
for (const img of imgs) {
|
for (const img of imgs) {
|
||||||
@@ -1273,7 +1307,7 @@ export async function transformRequest(
|
|||||||
if (inner.length < o.minToolResultChars) {
|
if (inner.length < o.minToolResultChars) {
|
||||||
bumpPassthrough(info, 'below_threshold');
|
bumpPassthrough(info, 'below_threshold');
|
||||||
rewritten.push(blk);
|
rewritten.push(blk);
|
||||||
} else if (!isCompressionProfitable(inner.length, o.cols)) {
|
} else if (!isCompressionProfitable(inner, o.cols, o.maxImagesPerToolResult)) {
|
||||||
bumpPassthrough(info, 'not_profitable');
|
bumpPassthrough(info, 'not_profitable');
|
||||||
rewritten.push(blk);
|
rewritten.push(blk);
|
||||||
} else {
|
} else {
|
||||||
@@ -1316,7 +1350,7 @@ export async function transformRequest(
|
|||||||
newInner.push(ib as TextBlock | ImageBlock);
|
newInner.push(ib as TextBlock | ImageBlock);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!isCompressionProfitable(innerText.length, o.cols)) {
|
if (!isCompressionProfitable(innerText, o.cols, o.maxImagesPerToolResult)) {
|
||||||
bumpPassthrough(info, 'not_profitable');
|
bumpPassthrough(info, 'not_profitable');
|
||||||
newInner.push(ib as TextBlock | ImageBlock);
|
newInner.push(ib as TextBlock | ImageBlock);
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
+47
-8
@@ -980,7 +980,10 @@ describe('transform', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('keeps <env> as text outside the image so cache_control stays stable', async () => {
|
it('keeps <env> as text outside the image so cache_control stays stable', async () => {
|
||||||
const staticSlab = 'claude.md ground truth.\n'.repeat(2200);
|
// Dense slab (long single line) so the row-aware break-even gate
|
||||||
|
// greenlights compression. Same total chars as the old short-line
|
||||||
|
// fixture but profitable: 1 image @ 2500 < 52800/4 = 13200 text.
|
||||||
|
const staticSlab = 'claude.md ground truth. '.repeat(2200);
|
||||||
const envBlock =
|
const envBlock =
|
||||||
"<env>\nWorking directory: /tmp/parityproj\nIs directory a git repo: Yes\nPlatform: darwin\nToday's date: 2026-05-18\n</env>";
|
"<env>\nWorking directory: /tmp/parityproj\nIs directory a git repo: Yes\nPlatform: darwin\nToday's date: 2026-05-18\n</env>";
|
||||||
const sys = staticSlab + '\n' + envBlock;
|
const sys = staticSlab + '\n' + envBlock;
|
||||||
@@ -1025,7 +1028,7 @@ describe('transform', () => {
|
|||||||
|
|
||||||
it('puts cache_control on the image only, never on the dynamic tail', async () => {
|
it('puts cache_control on the image only, never on the dynamic tail', async () => {
|
||||||
const sys =
|
const sys =
|
||||||
'claude.md\n'.repeat(5000) +
|
'x'.repeat(40000) +
|
||||||
'<env>\nWorking directory: /tmp/x\n</env>\n' +
|
'<env>\nWorking directory: /tmp/x\n</env>\n' +
|
||||||
'<context name="todoList">\n[ ] do thing\n</context>';
|
'<context name="todoList">\n[ ] do thing\n</context>';
|
||||||
const body = new TextEncoder().encode(
|
const body = new TextEncoder().encode(
|
||||||
@@ -1133,7 +1136,7 @@ describe('transform', () => {
|
|||||||
// The whole token-savings story collapses if the renderer is non-
|
// The whole token-savings story collapses if the renderer is non-
|
||||||
// deterministic, because identical system prompts on consecutive turns
|
// deterministic, because identical system prompts on consecutive turns
|
||||||
// would produce different image bytes → 0% cache hit. Guard rail.
|
// would produce different image bytes → 0% cache hit. Guard rail.
|
||||||
const sys = 'claude.md\n'.repeat(5000);
|
const sys = 'x'.repeat(40000);
|
||||||
const body = new TextEncoder().encode(
|
const body = new TextEncoder().encode(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
model: 'claude',
|
model: 'claude',
|
||||||
@@ -1238,7 +1241,7 @@ describe('transform', () => {
|
|||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
model: 'claude',
|
model: 'claude',
|
||||||
messages: [{ role: 'user', content: 'hi' }],
|
messages: [{ role: 'user', content: 'hi' }],
|
||||||
system: 'claude.md\n'.repeat(5000),
|
system: 'x'.repeat(40000),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const { body: outBytes } = await transformRequest(body);
|
const { body: outBytes } = await transformRequest(body);
|
||||||
@@ -1268,7 +1271,7 @@ describe('transform', () => {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
system: 'claude.md\n'.repeat(5000),
|
system: 'x'.repeat(40000),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const { body: outBytes, info } = await transformRequest(body);
|
const { body: outBytes, info } = await transformRequest(body);
|
||||||
@@ -1303,7 +1306,7 @@ describe('transform', () => {
|
|||||||
content: [{ type: 'text', text: shortReminder }],
|
content: [{ type: 'text', text: shortReminder }],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
system: 'claude.md\n'.repeat(5000),
|
system: 'x'.repeat(40000),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const { body: outBytes, info } = await transformRequest(body);
|
const { body: outBytes, info } = await transformRequest(body);
|
||||||
@@ -1335,7 +1338,7 @@ describe('transform', () => {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
system: 'claude.md\n'.repeat(5000),
|
system: 'x'.repeat(40000),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const { body: outBytes, info } = await transformRequest(body);
|
const { body: outBytes, info } = await transformRequest(body);
|
||||||
@@ -1370,7 +1373,7 @@ describe('transform', () => {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
system: 'claude.md\n'.repeat(5000),
|
system: 'x'.repeat(40000),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const { body: outBytes, info } = await transformRequest(body);
|
const { body: outBytes, info } = await transformRequest(body);
|
||||||
@@ -1534,6 +1537,42 @@ describe('transform', () => {
|
|||||||
expect(isCompressionProfitable(10_001, 20)).toBe(false);
|
expect(isCompressionProfitable(10_001, 20)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('isCompressionProfitable(string): row-aware → newline-heavy sparse content (~5500 chars/img) rejected as net-loss', () => {
|
||||||
|
// Regression for the -69% dashboard bug: the number-arg form estimates
|
||||||
|
// by chars/charsPerImage which assumes uniform line-fill. That assumes
|
||||||
|
// 14100 chars/image but renderTextToPngs actually packs ~141 visual
|
||||||
|
// rows/image — newline-heavy code/logs hit row cap WAY before char cap.
|
||||||
|
//
|
||||||
|
// 50000 chars of `x.md\n` is 5000 short lines → 5000 rows / 141 = 36
|
||||||
|
// images. 36 * 2500 = 90000 image tokens vs 50000/4 = 12500 text tokens.
|
||||||
|
// Massive net loss. Number-arg form would incorrectly accept (50000 chars
|
||||||
|
// / 14100 chars-per-img = 4 imgs → 10000 < 12500 → "profitable").
|
||||||
|
const sparse = 'x.md\n'.repeat(10_000);
|
||||||
|
expect(isCompressionProfitable(sparse, 100)).toBe(false);
|
||||||
|
expect(isCompressionProfitable(sparse.length, 100)).toBe(true); // back-compat: looser estimate
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isCompressionProfitable(string): row-aware → dense single-line content packs full-width and profits', () => {
|
||||||
|
// Same 50000 chars but as ONE line wraps to 100-char rows → 500 rows / 141
|
||||||
|
// = 4 images. 4 * 2500 = 10000 image tokens vs 50000/4 = 12500 text →
|
||||||
|
// profitable. Both forms agree on dense content.
|
||||||
|
const dense = 'x'.repeat(50_000);
|
||||||
|
expect(isCompressionProfitable(dense, 100)).toBe(true);
|
||||||
|
expect(isCompressionProfitable(dense.length, 100)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isCompressionProfitable(string, cols, cap): truncation cap lets 500KB 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 = 355 images), with cap=10 we accept (10*2500=25000 vs
|
||||||
|
// 500000/4=125000 text → win by 100k).
|
||||||
|
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(false); // 10k rows × 2500 way over
|
||||||
|
expect(isCompressionProfitable(log, 100, 10)).toBe(true); // capped, profits
|
||||||
|
});
|
||||||
|
|
||||||
it('isCompressionProfitable: smaller-cell atlas (Cozette-shape) would let 16k blocks become 1-image wins (cols proxy)', () => {
|
it('isCompressionProfitable: smaller-cell atlas (Cozette-shape) would let 16k blocks become 1-image wins (cols proxy)', () => {
|
||||||
// True smaller-cell test would need to mock ATLAS_CELL_H. We use cols as
|
// True smaller-cell test would need to mock ATLAS_CELL_H. We use cols as
|
||||||
// a proxy since CHARS_PER_IMAGE = cols × floor((1568−8)/cell_H) — doubling
|
// a proxy since CHARS_PER_IMAGE = cols × floor((1568−8)/cell_H) — doubling
|
||||||
|
|||||||
Reference in New Issue
Block a user