diff --git a/CHANGELOG.md b/CHANGELOG.md index 2a00fc8a..7a9ad62a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,12 @@ behavioral changes, patch = fixes). models (#159). - `build.mjs` resolves `tsc` via `typescript/package.json` instead of the removed `./bin/tsc` export, fixing builds on newer TypeScript. +- When the legible history geometry (#170) overflowed the image-byte budget on a + long session, the collapse was abandoned and the raw history forwarded as + text — a request larger than the render it refused, which the upstream 400s. + The collapse now degrades to the dense geometry to fit instead of bailing; + only a history too big for dense too keeps its text. New `history_degraded_dense` + event marks the fallback (#216). ## 0.12.1 — 2026-08-08 diff --git a/src/core/tracker.ts b/src/core/tracker.ts index ec53c916..66d00a8e 100644 --- a/src/core/tracker.ts +++ b/src/core/tracker.ts @@ -93,6 +93,10 @@ export interface TrackEvent { * was therefore allowed to repack for density. Pair with cache_read_tokens: * a repack that lands on a live cache would show as a cache_create spike. */ history_pack_fill?: boolean; + /** Set when the model's legible history render overflowed the image-byte + * budget and the collapse was re-rendered at the dense geometry to fit, + * rather than abandoned to a raw-text forward (#216). */ + history_degraded_dense?: boolean; /** Codepoints not in the glyph atlas. A spike means users type glyphs we don't ship — widen ATLAS_PROFILE. */ dropped_chars?: number; /** Top-20 dropped codepoints (U+HHHH keys) by frequency. Only present when dropped_chars > 0. */ @@ -305,6 +309,7 @@ export function toTrackEvent(ev: ProxyEvent): TrackEvent { if (info.historyFreezeStep !== undefined) out.history_freeze_step = info.historyFreezeStep; if (info.historyBudgetTrimmed) out.history_budget_trimmed = true; if (info.historyPackFill) out.history_pack_fill = true; + if (info.historyDegradedDense) out.history_degraded_dense = true; if (info.droppedChars !== undefined && info.droppedChars > 0) { out.dropped_chars = info.droppedChars; } diff --git a/src/core/transform.ts b/src/core/transform.ts index 82f5feff..846b756f 100644 --- a/src/core/transform.ts +++ b/src/core/transform.ts @@ -51,6 +51,7 @@ import { ANTHROPIC_MAX_IMAGES, ANTHROPIC_HISTORY_IMAGE_BUDGET, } from './history.js'; +import type { HistoryCollapseInfo } from './history.js'; import { noteHistoryRequest, recordFreezeStep } from './session-state.js'; import type { GptHistoryOptions } from './openai-history.js'; import { CACHE_CREATE_RATE, CACHE_READ_RATE } from './baseline.js'; @@ -812,6 +813,11 @@ export interface TransformInfo { * didn't (exclude from rollup — cacheable=0 fallback is dishonest). 'failed': no * baseline. undefined: no probe attempted. */ baselineProbeStatus?: 'ok' | 'partial' | 'failed'; + /** The model's legible history render (jetbrains-mono-14, #170) overflowed the + * decoded image-byte budget, so the collapse was re-rendered at the dense + * geometry and admitted instead of being abandoned to a raw-text forward + * (#216). Diagnostic only. */ + historyDegradedDense?: boolean; } // --- helpers --------------------------------------------------------------- @@ -1918,6 +1924,79 @@ function historyGridTuning( return { imageBudget, packFill: session.cold, minFreezeStep: session.minFreezeStep }; } +/** Collapse the history at the model's default (possibly legible) history + * geometry, and — when that render overflows the decoded image-byte budget — + * retry once at the dense geometry before giving up. + * + * #216: since #170, misread-prone Claude models (e.g. `claude-opus-5`) default + * to the legible history geometry (`jetbrains-mono-14`), which renders several + * times heavier per char than dense (`spleen-5x8`). On a long session the + * legible collapse can exceed the 18 MiB headroom while the dense render still + * fits. The atomic-admission check then abandoned the collapse and forwarded + * the raw history as text — a request LARGER than the render it refused (raw + * forwards of 1.65-1.73M tokens observed, upstream 400s). Degrading to dense + * keeps the collapse (and its token saving); only a history too big for even + * the dense geometry falls through to the original text passthrough. */ +async function collapseHistoryWithinByteBudget( + messages: Message[], + info: TransformInfo, + o: Required, + opts: TransformOptions, + protectedPrefix: number, + tuning: { imageBudget: number; packFill: boolean; minFreezeStep: number }, +): Promise<{ messages: Message[]; info: HistoryCollapseInfo; degradedToDense: boolean }> { + const historyCpt = opts.charsPerToken !== undefined + ? o.charsPerToken + : HISTORY_CHARS_PER_TOKEN; + const horizon = Math.max(1, Math.floor(o.historyAmortizationHorizon)); + const collapseAt = (geometry: GateGeometry) => { + // Gate with the same model geometry the history renderer will use. The + // symmetric warm-cache burn (priorWarmImageTokens) is passed through here + // too: without it the history gate flipped sessions out of image mode even + // when symmetric burn would have kept the slab gate in (prod 2026-05-23, + // three-turn sessions paying cache_create every turn). + const profitable = (text: string, _cols: number): boolean => + isCompressionProfitableAmortized( + text, geometry.cols, undefined, historyCpt, horizon, + o.priorWarmTokens, o.priorWarmImageTokens, true, geometry.maxChars, geometry, + ); + return collapseHistory(messages, profitable, { + cols: geometry.cols, + protectedPrefix, + reflow: o.reflow, + style: geometry.style, + maxHeightPx: geometry.maxHeightPx, + pageChars: geometry.maxChars, + imageBudget: tuning.imageBudget, + packFill: tuning.packFill, + minFreezeStep: tuning.minFreezeStep, + }); + }; + const overflows = (histInfo: HistoryCollapseInfo): boolean => + histInfo.collapsedTurns > 0 && + histInfo.collapsedImageBytes > imageByteHeadroom(info, o.maxImageBytes); + + const first = await collapseAt(historyGateGeometry(o, opts.cols !== undefined)); + if (!overflows(first.info)) return { ...first, degradedToDense: false }; + + // The retry only helps when the model actually renders history legibly; when + // the history geometry already IS the dense one (Fable, GPT), re-rendering + // would reproduce the same overflow. `historyGateGeometry` diverges from dense + // exactly when the profile sets a history override, so mirror that condition. + const profile = o.model ? resolveGptProfile(o.model) : undefined; + const rendersLegibleHistory = + profile?.historyStripCols !== undefined || profile?.historyStyle !== undefined; + if (!rendersLegibleHistory) return { ...first, degradedToDense: false }; + + const retry = await collapseAt(denseGateGeometry(o)); + // Keep the dense collapse only when it fits; otherwise the original atomic + // admission stands (a history too big for any geometry keeps its text). + if (retry.info.collapsedTurns > 0 && !overflows(retry.info)) { + return { ...retry, degradedToDense: true }; + } + return { ...first, degradedToDense: false }; +} + async function runHistoryCollapseAndFinalize( req: MessagesRequest, info: TransformInfo, @@ -1936,24 +2015,6 @@ async function runHistoryCollapseAndFinalize( info.imageBudgetSkips = (info.imageBudgetSkips ?? 0) + 1; info.historyReason ??= 'too_many_images'; } else if (Array.isArray(req.messages) && req.messages.length > 0) { - const historyCpt = opts.charsPerToken !== undefined - ? o.charsPerToken - : HISTORY_CHARS_PER_TOKEN; - const horizon = Math.max(1, Math.floor(o.historyAmortizationHorizon)); - // Pass the symmetric warm-cache burn through to the history-collapse - // gate as well. The slab gate alone got the symmetric treatment, which - // let the history gate flip a session out of image mode even when - // symmetric burn would have kept the slab gate in. Production data - // 2026-05-23 showed three-turn sessions paying cache_create every - // turn because the history gate ignored priorWarmImageTokens. - const historyGeometry = historyGateGeometry(o, opts.cols !== undefined); - const historyProfitable = (text: string, cols: number): boolean => { - // Gate with the same model profile used by the history renderer. - return isCompressionProfitableAmortized( - text, historyGeometry.cols, undefined, historyCpt, horizon, - o.priorWarmTokens, o.priorWarmImageTokens, true, historyGeometry.maxChars, historyGeometry, - ); - }; // The slab needs no shield here: this path runs only when the slab did NOT // image (it stays as text in req.system). But project instructions do. // CLAUDE.md arrives in the FIRST user message wrapped in , @@ -1964,31 +2025,20 @@ async function runHistoryCollapseAndFinalize( // non-collapse path already keeps as text below. const protectedPrefix = firstMessageHasSystemReminder(req.messages) ? 1 : 0; const tuning = historyGridTuning(info); - const { messages: newMessages, info: histInfo } = await collapseHistory( - req.messages, - historyProfitable, - { - cols: historyGeometry.cols, - protectedPrefix, - reflow: o.reflow, - style: historyGeometry.style, - maxHeightPx: historyGeometry.maxHeightPx, - pageChars: historyGeometry.maxChars, - imageBudget: tuning.imageBudget, - packFill: tuning.packFill, - minFreezeStep: tuning.minFreezeStep, - }, - ); + // Collapses at the model geometry, degrading legible -> dense if that render + // overflows the byte budget instead of abandoning the collapse (#216). + const { messages: newMessages, info: histInfo, degradedToDense } = + await collapseHistoryWithinByteBudget(req.messages, info, o, opts, protectedPrefix, tuning); recordFreezeStep(info.firstUserSha8, histInfo.freezeStep); if (histInfo.freezeStep !== undefined) info.historyFreezeStep = histInfo.freezeStep; if (histInfo.budgetTrimmed) info.historyBudgetTrimmed = true; if (tuning.packFill) info.historyPackFill = true; + if (degradedToDense) info.historyDegradedDense = true; // Atomic admission by weight. The collapse is one semantic group: applying // it means every collapsed message becomes pages, so a group that does not // fit the byte budget is not applied at all and the original text stands. - // Rendering it first and discarding it costs CPU, which is the cheap half of - // the trade: the alternative is estimating PNG size from character counts and - // being wrong on the request that mattered. + // Reaching the bail here means even the dense re-render overflowed, so no + // geometry fits and the original text is the only safe forward. if ( histInfo.collapsedTurns > 0 && histInfo.collapsedImageBytes > imageByteHeadroom(info, o.maxImageBytes) @@ -2538,45 +2588,24 @@ export async function transformRequest( // protectedPrefix excludes the slab-bearing first user message — collapsing it // would reduce slab images to [image] placeholders and destroy the cache anchor. if (Array.isArray(req.messages) && req.messages.length > 0) { - const historyCpt = opts.charsPerToken !== undefined - ? o.charsPerToken - : HISTORY_CHARS_PER_TOKEN; - const horizon = Math.max(1, Math.floor(o.historyAmortizationHorizon)); - const historyGeometry = historyGateGeometry(o, opts.cols !== undefined); - const historyProfitable = (text: string, cols: number): boolean => { - // Gate with the same model profile used by the history renderer. - return isCompressionProfitableAmortized( - text, historyGeometry.cols, undefined, historyCpt, horizon, - o.priorWarmTokens, o.priorWarmImageTokens, true, historyGeometry.maxChars, historyGeometry, - ); - }; const slabAnchorIdx = (req.messages ?? []).findIndex((m) => m.role === 'user'); const tuning = historyGridTuning(info); - const { messages: newMessages, info: histInfo } = await collapseHistory( - req.messages, - historyProfitable, - { - cols: historyGeometry.cols, - protectedPrefix: slabAnchorIdx >= 0 ? slabAnchorIdx + 1 : 0, - reflow: o.reflow, - style: historyGeometry.style, - maxHeightPx: historyGeometry.maxHeightPx, - pageChars: historyGeometry.maxChars, - imageBudget: tuning.imageBudget, - packFill: tuning.packFill, - minFreezeStep: tuning.minFreezeStep, - }, - ); + // Collapses at the model geometry, degrading legible -> dense if that render + // overflows the byte budget instead of abandoning the collapse (#216). + const { messages: newMessages, info: histInfo, degradedToDense } = + await collapseHistoryWithinByteBudget( + req.messages, info, o, opts, slabAnchorIdx >= 0 ? slabAnchorIdx + 1 : 0, tuning, + ); recordFreezeStep(info.firstUserSha8, histInfo.freezeStep); if (histInfo.freezeStep !== undefined) info.historyFreezeStep = histInfo.freezeStep; if (histInfo.budgetTrimmed) info.historyBudgetTrimmed = true; if (tuning.packFill) info.historyPackFill = true; + if (degradedToDense) info.historyDegradedDense = true; // Atomic admission by weight. The collapse is one semantic group: applying // it means every collapsed message becomes pages, so a group that does not // fit the byte budget is not applied at all and the original text stands. - // Rendering it first and discarding it costs CPU, which is the cheap half of - // the trade: the alternative is estimating PNG size from character counts and - // being wrong on the request that mattered. + // Reaching the bail here means even the dense re-render overflowed, so no + // geometry fits and the original text is the only safe forward. if ( histInfo.collapsedTurns > 0 && histInfo.collapsedImageBytes > imageByteHeadroom(info, o.maxImageBytes) diff --git a/tests/image-byte-budget.test.ts b/tests/image-byte-budget.test.ts index d0cc9b1e..79372e60 100644 --- a/tests/image-byte-budget.test.ts +++ b/tests/image-byte-budget.test.ts @@ -18,7 +18,7 @@ * * Run just this file: pnpm vitest run tests/image-byte-budget.test.ts */ -import { beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { countNativeImageBytes, imageByteHeadroom, @@ -147,6 +147,110 @@ describe('a group that does not fit keeps its text', () => { }); }); +describe('a legible history that overflows degrades to dense before abandoning the collapse (#216)', () => { + // Since #170, misread-prone Claude ids (claude-opus-5) default to the legible + // history geometry (jetbrains-mono-14), which renders several times heavier per + // char than dense (spleen-5x8). On a long session the legible collapse can blow + // the byte budget while the dense render still fits. The old atomic-admission + // check then abandoned the collapse and forwarded the RAW history as text — a + // request larger than the render it refused, which the upstream 400s. The fix + // re-renders at dense and admits it; only a history too big for dense too still + // falls back to text. + const TURNS = 100; + const REP = 50; + const IMAGED_SLAB_CHARS = 12_000; // above minCompressChars -> slab is imaged (site: transformRequest inline) + const TINY_SLAB_CHARS = 50; // below minCompressChars -> slab stays text (site: runHistoryCollapseAndFinalize) + const HUGE_BUDGET = 100 * 1024 * 1024; // never binds; used only to measure the render size + + beforeEach(() => { + resetSessionState(); + process.env.PXPIPE_MODELS = 'claude-opus-5,claude-fable-5'; + }); + afterEach(() => { + delete process.env.PXPIPE_MODELS; + }); + + /** A long collapsible history behind a slab of the given size. */ + function longHistory(model: string, slabChars: number): Uint8Array { + const bulk = + 'commit a1b2c3d4e5f6 sha 9f8e7d6c ts 2026-07-04T14:50:50Z ratio 0.734 $30.99 Tampa-0 stock 120 reorder 40. '; + const messages: unknown[] = []; + for (let i = 0; i < TURNS; i++) { + messages.push({ + role: i % 2 === 0 ? 'user' : 'assistant', + content: [{ type: 'text', text: bulk.repeat(REP) + ` turn ${i}` }], + }); + } + messages.push({ role: 'user', content: [{ type: 'text', text: 'continue' }] }); + return enc({ + model, + max_tokens: 1024, + system: [{ type: 'text', text: 'S'.repeat(slabChars), cache_control: { type: 'ephemeral' } }], + messages, + }); + } + + async function render(model: string, slabChars: number, maxImageBytes: number) { + resetSessionState(); + return transformRequest(longHistory(model, slabChars), { model, maxImageBytes }); + } + + // Both admission sites share the bug; exercise each. Site 2 is the inline path + // in transformRequest (slab imaged); site 1 is runHistoryCollapseAndFinalize + // (slab below the gate, history still collapses — the "tiny system, huge + // messages" shape). + for (const [siteLabel, slabChars] of [ + ['imaged-slab path', IMAGED_SLAB_CHARS], + ['text-slab path', TINY_SLAB_CHARS], + ] as const) { + it(`re-renders the collapse at dense instead of forwarding raw text (${siteLabel})`, async () => { + // Measure both geometries with a non-binding budget: legible must be the + // heavier of the two, or there is nothing to degrade. + const dense = (await render('claude-fable-5', slabChars, HUGE_BUDGET)).info; + const legible = (await render('claude-opus-5', slabChars, HUGE_BUDGET)).info; + expect(dense.historyReason, 'fixture must collapse at dense').toBe('collapsed'); + expect(legible.historyReason, 'fixture must collapse at legible').toBe('collapsed'); + expect(legible.imageBytes).toBeGreaterThan(dense.imageBytes); + + // A budget between the two: legible overflows, dense fits. + const between = Math.floor((dense.imageBytes + legible.imageBytes) / 2); + const { info } = await render('claude-opus-5', slabChars, between); + + // Fixed: the collapse is admitted at the dense geometry, not abandoned. + expect(info.historyReason).toBe('collapsed'); + expect(info.historyDegradedDense).toBe(true); + expect(info.imageByteSkips ?? 0).toBe(0); + // The admitted render stays within the budget it was given, and matches + // the dense measurement — i.e. it really is the dense re-render, not the + // legible one squeaking under a coincidental budget. + expect(info.imageBytes).toBeLessThanOrEqual(between); + expect(info.imageBytes).toBe(dense.imageBytes); + }); + } + + it('leaves Fable (already dense) on the original atomic-admission bail', async () => { + // Fable renders history dense by default, so there is no legible->dense step + // to take: a budget under its render must still bail, not loop. + const dense = (await render('claude-fable-5', IMAGED_SLAB_CHARS, HUGE_BUDGET)).info; + const tight = Math.floor(dense.imageBytes / 2); + const { info } = await render('claude-fable-5', IMAGED_SLAB_CHARS, tight); + expect(info.historyReason).toBe('image_bytes'); + expect(info.imageByteSkips ?? 0).toBeGreaterThan(0); + expect(info.historyDegradedDense ?? false).toBe(false); + }); + + it('still bails when even the dense render overflows the budget', async () => { + // A history too big for any geometry keeps its text — the atomic-admission + // guarantee must survive the degrade path. + const dense = (await render('claude-fable-5', IMAGED_SLAB_CHARS, HUGE_BUDGET)).info; + const tooTight = Math.floor(dense.imageBytes / 2); // below the dense render too + const { info } = await render('claude-opus-5', IMAGED_SLAB_CHARS, tooTight); + expect(info.historyReason).toBe('image_bytes'); + expect(info.imageByteSkips ?? 0).toBeGreaterThan(0); + expect(info.historyDegradedDense ?? false).toBe(false); + }); +}); + describe('the caller outranks us', () => { beforeEach(() => resetSessionState());