mirror of
https://github.com/teamchong/pxpipe.git
synced 2026-07-22 02:02:51 +02:00
history: Variant C closed-tool-sequence collapse + honest per-session savings
Two distinct improvements in one slice:
1. History-image compression (Variant C — opt-in, default OFF).
New src/core/history.ts module:
- findClosedPrefixBoundary(): walks tool_use_id openSet, returns largest
fully-closed prefix index. Mid-flight tool_use blocks bail the collapse.
- blocksToText(): linearises content blocks for the history image. Drops
thinking blocks (per Opus 4.7+ docs: only most-recent must be bit-perfect),
renders tool_use args + tool_result inner text, emits [image] placeholder
for nested images.
- collapseHistory(): orchestrates. Applies isCompressionProfitable() gate.
Returns a synthesized {role:'user', content:[text,image,text]} prepended
message + the elided turns dropped from messages[].
New TransformOptions: compressHistory (default false), historyKeepTail (4),
historyMinPrefix (10). Wired through src/node.ts + src/worker.ts as
COMPRESS_HISTORY / HISTORY_KEEP_TAIL / HISTORY_MIN_PREFIX env vars.
Cache_control: history-image carries NO cache_control on this first cut.
Static slab keeps its 1 breakpoint. Conservative — telemetry will reveal
whether the cache-miss rate on history images warrants the round-3
rebalance in a follow-up slice.
29 new tests in tests/history.test.ts: closed-prefix detection (parallel
tool calls, mid-flight, openSet straddling), block linearisation, full
collapse flow, isCompressionProfitable gate firing on small histories.
2. Per-session savings: honest formula (fixes task #63).
Previous src/sessions.ts computed saved = orig_chars - image_bytes — a
nonsense comparison of chars to PNG bytes, then clamped via Math.max(0,...).
Result: charsSaved/tokensSavedEst was 0 for every session including ones
with hundreds of MB of compressed traffic.
New formula mirrors src/dashboard.ts:
textTokens = orig_chars / 4
imageTokens = image_count × 2500
tokensSaved = textTokens − imageTokens (CAN be negative)
Negatives are NOT clamped — they surface the cost-bleed on small-block
compressions that the per-block break-even check is supposed to prevent.
2 new tests: positive-net case, all-negative case (must report negative).
229/229 tests passing. Bundle ~500 KB gzipped.
This commit is contained in:
@@ -0,0 +1,325 @@
|
||||
/**
|
||||
* History-image compression (Variant C).
|
||||
*
|
||||
* Walks `messages[]` left-to-right tracking open `tool_use_id`s. Identifies
|
||||
* the largest **closed-tool-sequence prefix** — the longest run from the
|
||||
* head of the conversation that has no straddling `tool_use` references
|
||||
* into the live tail. Renders that prefix's text content into one or more
|
||||
* PNG image blocks and replaces the prefix with ONE synthetic user message:
|
||||
*
|
||||
* { role: 'user', content: [
|
||||
* { type: 'text', text: '[Earlier in this conversation:]' },
|
||||
* { type: 'image', source: {...} }, // (1..N images)
|
||||
* { type: 'text', text: '[End of earlier context.]' },
|
||||
* ]}
|
||||
*
|
||||
* Live tail (the last `keepTail` turns + anything inside an open tool
|
||||
* sequence) stays as text. The most-recent assistant turn — the one
|
||||
* carrying Opus 4.7's `thinking` signature — is always in the live tail
|
||||
* by construction (it's at index `messages.length - 1` when present).
|
||||
*
|
||||
* The synthesized user message uses `role: 'user'` because Anthropic
|
||||
* **forbids `image` blocks inside `role: 'assistant'`** (see
|
||||
* `/tmp/pixelpipe-history-compression.md` line 14 and `types.ts:58`
|
||||
* comments). Self-attribution ("I previously said X") is the price.
|
||||
*
|
||||
* `thinking` blocks inside the collapsed range are dropped from the
|
||||
* rendered text — per the spec's Check 2, only the most-recent
|
||||
* assistant-with-tool_use's thinking content must round-trip bit-perfect,
|
||||
* and that turn is in the live tail by construction.
|
||||
*
|
||||
* **Cache-control**: caller controls whether the history-image carries
|
||||
* pixelpipe's ephemeral breakpoint. This module returns the image blocks
|
||||
* unmarked; the caller in `transform.ts` decides placement.
|
||||
*
|
||||
* Spec source: `/tmp/pixelpipe-history-compression.md` Variant C section
|
||||
* (round 3, lines 346-364 + check-3 cache-control discussion).
|
||||
*/
|
||||
|
||||
import type { ContentBlock, ImageBlock, Message, TextBlock, ToolUseBlock, ToolResultBlock } from './types.js';
|
||||
import { renderTextToPngs } from './render.js';
|
||||
import { bytesToBase64 } from './png.js';
|
||||
|
||||
/** Function predicate signature for the break-even gate. Passed in by the
|
||||
* caller (transform.ts) rather than imported here to keep `src/core/history.ts`
|
||||
* free of a cycle with `src/core/transform.ts`. transform.ts already imports
|
||||
* history.ts to invoke `collapseHistory`; importing back the other way would
|
||||
* create an evaluation-order trap. */
|
||||
export type ProfitableFn = (textLen: number, cols: number) => boolean;
|
||||
|
||||
/** Configuration for history collapse. */
|
||||
export interface HistoryCollapseOptions {
|
||||
/** Number of turns at the tail (most recent) to KEEP as text. Default 4. */
|
||||
keepTail: number;
|
||||
/** Minimum number of turns in the collapsable prefix before we attempt
|
||||
* the rolling-window re-roll. Below this, leaving them as text costs
|
||||
* almost nothing and the cache-amortization math doesn't work. Default 10. */
|
||||
minCollapsePrefix: number;
|
||||
/** Soft-wrap column count for the renderer. Should match the host's
|
||||
* configured `cols` so the history image visually matches the system
|
||||
* image. Default 100. */
|
||||
cols: number;
|
||||
}
|
||||
|
||||
export const HISTORY_DEFAULTS: HistoryCollapseOptions = {
|
||||
keepTail: 4,
|
||||
minCollapsePrefix: 10,
|
||||
cols: 100,
|
||||
};
|
||||
|
||||
/** Per-request telemetry surfaced back to TransformInfo. */
|
||||
export interface HistoryCollapseInfo {
|
||||
/** Number of turns collapsed into the history image. */
|
||||
collapsedTurns: number;
|
||||
/** Total chars of text that went into the history image. */
|
||||
collapsedChars: number;
|
||||
/** Number of PNG image blocks emitted for the history (≥1 if collapsed). */
|
||||
collapsedImages: number;
|
||||
/** Total PNG bytes emitted. */
|
||||
collapsedImageBytes: number;
|
||||
/** Why we didn't collapse — populated only when no collapse happened. */
|
||||
reason?:
|
||||
| 'no_history'
|
||||
| 'prefix_too_short'
|
||||
| 'no_closed_prefix'
|
||||
| 'not_profitable'
|
||||
| 'render_empty';
|
||||
/** Dropped codepoints from the history render, merged into the
|
||||
* transform-wide map by the caller. */
|
||||
droppedChars: number;
|
||||
droppedCodepoints: Map<number, number>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the largest index `i*` in `[0..messages.length - keepTail - 1]`
|
||||
* such that all `tool_use_id`s issued by assistant turns in `[0..i*]` are
|
||||
* matched by `tool_result`s also in `[0..i*]`. Returns `-1` if no such
|
||||
* boundary exists (i.e. every potential boundary has a tool_use straddling
|
||||
* into the live tail).
|
||||
*
|
||||
* Algorithm: walk left-to-right. Maintain an `openSet` of unmatched
|
||||
* tool_use_ids. After processing each message, record the current openSet
|
||||
* size. The closed-prefix boundary is the **last** index ≤ cutoff at which
|
||||
* openSet was empty AFTER processing.
|
||||
*
|
||||
* NOTE: this is robust to interleaved tool_use sequences (e.g. two parallel
|
||||
* tool calls in one assistant turn followed by two tool_results in the
|
||||
* next user turn). The openSet tracking handles that correctly.
|
||||
*/
|
||||
export function findClosedPrefixBoundary(
|
||||
messages: Message[],
|
||||
cutoffExclusive: number,
|
||||
): number {
|
||||
if (cutoffExclusive <= 0) return -1;
|
||||
const openSet = new Set<string>();
|
||||
let lastClosed = -1;
|
||||
const limit = Math.min(cutoffExclusive, messages.length);
|
||||
for (let i = 0; i < limit; i++) {
|
||||
const msg = messages[i]!;
|
||||
if (!Array.isArray(msg.content)) {
|
||||
// Plain string content — no tool blocks possible. Just close-or-stay.
|
||||
if (openSet.size === 0) lastClosed = i;
|
||||
continue;
|
||||
}
|
||||
if (msg.role === 'assistant') {
|
||||
for (const blk of msg.content) {
|
||||
if (blk && (blk as ToolUseBlock).type === 'tool_use') {
|
||||
const id = (blk as ToolUseBlock).id;
|
||||
if (typeof id === 'string') openSet.add(id);
|
||||
}
|
||||
}
|
||||
} else if (msg.role === 'user') {
|
||||
for (const blk of msg.content) {
|
||||
if (blk && (blk as ToolResultBlock).type === 'tool_result') {
|
||||
const id = (blk as ToolResultBlock).tool_use_id;
|
||||
if (typeof id === 'string') openSet.delete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (openSet.size === 0) lastClosed = i;
|
||||
}
|
||||
return lastClosed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Linearise a content-block array to a single string for OCR. Drops
|
||||
* `thinking` blocks (Opus 4.7+ only requires bit-perfect on the most-recent
|
||||
* assistant-with-tool_use, which is in the live tail by construction).
|
||||
* Tool-use input args and tool-result content are included so the model
|
||||
* has full context. Inline images and tool_result images are reduced to a
|
||||
* `[image]` placeholder — embedding them in the history image would
|
||||
* double-encode for no benefit.
|
||||
*/
|
||||
export function blocksToText(content: string | ContentBlock[]): string {
|
||||
if (typeof content === 'string') return content;
|
||||
const parts: string[] = [];
|
||||
for (const blk of content) {
|
||||
if (!blk || typeof blk !== 'object') continue;
|
||||
const t = (blk as { type?: string }).type;
|
||||
switch (t) {
|
||||
case 'text':
|
||||
parts.push((blk as TextBlock).text);
|
||||
break;
|
||||
case 'tool_use': {
|
||||
const tu = blk as ToolUseBlock;
|
||||
// Render as a labelled block so the model can re-attribute. The
|
||||
// arg JSON is included verbatim — for very large args this will
|
||||
// bloat the history image, but the alternative (dropping args)
|
||||
// breaks model self-attribution worse than the bloat does.
|
||||
let argsStr: string;
|
||||
try {
|
||||
argsStr = JSON.stringify(tu.input, null, 2);
|
||||
} catch {
|
||||
argsStr = String(tu.input);
|
||||
}
|
||||
parts.push(`[tool_use ${tu.name}]\n${argsStr}`);
|
||||
break;
|
||||
}
|
||||
case 'tool_result': {
|
||||
const tr = blk as ToolResultBlock;
|
||||
const inner = tr.content;
|
||||
let innerText: string;
|
||||
if (typeof inner === 'string') {
|
||||
innerText = inner;
|
||||
} else if (Array.isArray(inner)) {
|
||||
const subParts: string[] = [];
|
||||
for (const sub of inner) {
|
||||
if (!sub || typeof sub !== 'object') continue;
|
||||
if ((sub as TextBlock).type === 'text') {
|
||||
subParts.push((sub as TextBlock).text);
|
||||
} else if ((sub as ImageBlock).type === 'image') {
|
||||
subParts.push('[image]');
|
||||
}
|
||||
}
|
||||
innerText = subParts.join('\n');
|
||||
} else {
|
||||
innerText = '';
|
||||
}
|
||||
const errMark = tr.is_error === true ? ' (error)' : '';
|
||||
parts.push(`[tool_result${errMark}]\n${innerText}`);
|
||||
break;
|
||||
}
|
||||
case 'image':
|
||||
parts.push('[image]');
|
||||
break;
|
||||
// 'thinking' and any other block type → drop silently.
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
return parts.join('\n\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize messages `[0..upToExclusive]` to a single OCR-friendly text
|
||||
* blob. Each turn is prefixed with `--- role ---` so the model can parse
|
||||
* the conversation back out. Empty turns are skipped.
|
||||
*/
|
||||
export function messagesToHistoryText(
|
||||
messages: Message[],
|
||||
upToExclusive: number,
|
||||
): string {
|
||||
const out: string[] = [];
|
||||
for (let i = 0; i < upToExclusive; i++) {
|
||||
const m = messages[i]!;
|
||||
const body = blocksToText(m.content);
|
||||
if (!body.trim()) continue;
|
||||
const tag = m.role === 'assistant' ? 'assistant' : 'user';
|
||||
out.push(`--- ${tag} ---\n${body}`);
|
||||
}
|
||||
return out.join('\n\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to collapse a closed-prefix run of `messages` into one synthetic
|
||||
* user message containing 1+ history images. Returns the rewritten
|
||||
* messages array (a new array; original is not mutated) and telemetry.
|
||||
*
|
||||
* On any "do not collapse" path (no prefix, too few turns, not profitable,
|
||||
* empty render), returns the original messages unchanged with a reason.
|
||||
*
|
||||
* Caller is responsible for cache_control placement on the returned image
|
||||
* blocks — this function returns them with NO `cache_control` set.
|
||||
*/
|
||||
export async function collapseHistory(
|
||||
messages: Message[],
|
||||
isProfitable: ProfitableFn,
|
||||
opts: Partial<HistoryCollapseOptions> = {},
|
||||
): Promise<{ messages: Message[]; info: HistoryCollapseInfo }> {
|
||||
const o: HistoryCollapseOptions = { ...HISTORY_DEFAULTS, ...opts };
|
||||
const info: HistoryCollapseInfo = {
|
||||
collapsedTurns: 0,
|
||||
collapsedChars: 0,
|
||||
collapsedImages: 0,
|
||||
collapsedImageBytes: 0,
|
||||
droppedChars: 0,
|
||||
droppedCodepoints: new Map(),
|
||||
};
|
||||
if (!messages || messages.length === 0) {
|
||||
info.reason = 'no_history';
|
||||
return { messages: messages ?? [], info };
|
||||
}
|
||||
// The live tail must contain at least `keepTail` messages. The boundary
|
||||
// search cuts off at `len - keepTail` so the tail is always preserved.
|
||||
const cutoff = messages.length - o.keepTail;
|
||||
const boundary = findClosedPrefixBoundary(messages, cutoff);
|
||||
if (boundary < 0) {
|
||||
info.reason = 'no_closed_prefix';
|
||||
return { messages, info };
|
||||
}
|
||||
// boundary is the last index INCLUDED in the collapse. Need at least
|
||||
// `minCollapsePrefix` turns to bother (cache-amortization math from
|
||||
// round-3 only works at scale; collapsing 2-3 turns is net cost).
|
||||
const collapseLen = boundary + 1;
|
||||
if (collapseLen < o.minCollapsePrefix) {
|
||||
info.reason = 'prefix_too_short';
|
||||
return { messages, info };
|
||||
}
|
||||
// Serialize and gate on break-even.
|
||||
const text = messagesToHistoryText(messages, collapseLen);
|
||||
if (!text || text.length === 0) {
|
||||
info.reason = 'render_empty';
|
||||
return { messages, info };
|
||||
}
|
||||
if (!isProfitable(text.length, o.cols)) {
|
||||
info.reason = 'not_profitable';
|
||||
info.collapsedChars = text.length; // surface what we DIDN'T compress
|
||||
return { messages, info };
|
||||
}
|
||||
// Render. No cache_control here — caller decides placement.
|
||||
const imgs = await renderTextToPngs(text, o.cols);
|
||||
if (imgs.length === 0) {
|
||||
info.reason = 'render_empty';
|
||||
return { messages, info };
|
||||
}
|
||||
const imageBlocks: ImageBlock[] = [];
|
||||
for (const img of imgs) {
|
||||
imageBlocks.push({
|
||||
type: 'image',
|
||||
source: {
|
||||
type: 'base64',
|
||||
media_type: 'image/png',
|
||||
data: bytesToBase64(img.png),
|
||||
},
|
||||
});
|
||||
info.collapsedImageBytes += img.png.length;
|
||||
info.droppedChars += img.droppedChars;
|
||||
for (const [cp, n] of img.droppedCodepoints) {
|
||||
info.droppedCodepoints.set(cp, (info.droppedCodepoints.get(cp) ?? 0) + n);
|
||||
}
|
||||
}
|
||||
// Build the synthetic user message.
|
||||
const syntheticUser: Message = {
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: '[Earlier in this conversation:]' },
|
||||
...imageBlocks,
|
||||
{ type: 'text', text: '[End of earlier context.]' },
|
||||
],
|
||||
};
|
||||
const tail = messages.slice(collapseLen);
|
||||
info.collapsedTurns = collapseLen;
|
||||
info.collapsedChars = text.length;
|
||||
info.collapsedImages = imageBlocks.length;
|
||||
return { messages: [syntheticUser, ...tail], info };
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import type {
|
||||
import { renderTextToPngs, MAX_HEIGHT_PX, PAD_Y } from './render.js';
|
||||
import { bytesToBase64 } from './png.js';
|
||||
import { ATLAS_CELL_H } from './atlas.js';
|
||||
import { collapseHistory } from './history.js';
|
||||
|
||||
export interface TransformOptions {
|
||||
/** Master switch — false makes this a no-op pass-through. */
|
||||
@@ -55,6 +56,22 @@ export interface TransformOptions {
|
||||
* rendering so the request stays under Anthropic's 100-image-per-request
|
||||
* cap even when a single tool dumps a huge log. Default 10. */
|
||||
maxImagesPerToolResult?: number;
|
||||
/** Variant C history-image compression: walk `messages[]` from the head,
|
||||
* find the largest closed-tool-sequence prefix, render its text into one
|
||||
* prepended user message with image blocks, and collapse those messages
|
||||
* out of the live tail. Off by default — round-3 spec marked this as
|
||||
* MARGINAL (~1% per-call cost reduction) with HIGH risk on cache topology.
|
||||
* Enable opt-in once telemetry confirms the cache breakpoint won't fight
|
||||
* with Claude Code's own upstream breakpoint placement. */
|
||||
compressHistory?: boolean;
|
||||
/** Number of tail turns to KEEP as text when `compressHistory` is on. The
|
||||
* most-recent assistant turn (carrying Opus 4.7's thinking signature) is
|
||||
* always in the tail by construction. Default 4. */
|
||||
historyKeepTail?: number;
|
||||
/** Minimum closed-prefix turn count before we bother collapsing. Cache-
|
||||
* amortization math from round-3 only pays out at scale — collapsing 2-3
|
||||
* turns costs more in image overhead than it saves. Default 10. */
|
||||
historyMinPrefix?: number;
|
||||
}
|
||||
|
||||
const DEFAULTS: Required<TransformOptions> = {
|
||||
@@ -82,6 +99,13 @@ const DEFAULTS: Required<TransformOptions> = {
|
||||
// `find` over a big tree or `grep -r` can easily exceed this; the paging
|
||||
// marker tells the model what was elided. Tuneable per session.
|
||||
maxImagesPerToolResult: 10,
|
||||
// Variant C history-image: OFF by default. Round-3 measurement put the
|
||||
// savings at ~1% per call against ~3% cost-bleed risk if cache topology
|
||||
// turns out wrong upstream. Enable via env / CLI flag once telemetry
|
||||
// shows the static-slab breakpoint still hits when this is wired.
|
||||
compressHistory: false,
|
||||
historyKeepTail: 4,
|
||||
historyMinPrefix: 10,
|
||||
};
|
||||
|
||||
// --- per-block break-even check ---
|
||||
@@ -233,6 +257,26 @@ export interface TransformInfo {
|
||||
truncatedToolResults?: number;
|
||||
/** Total chars elided by paging across all tool_results this request. */
|
||||
omittedChars?: number;
|
||||
/** Variant C history-image: how many original `messages[]` entries got
|
||||
* collapsed into the prepended synthetic user message. 0 / unset when
|
||||
* no collapse happened (compressHistory off, no closed prefix, etc.). */
|
||||
collapsedTurns?: number;
|
||||
/** Variant C: total chars of text serialized into the history image(s)
|
||||
* before render (pre-OCR loss). */
|
||||
collapsedChars?: number;
|
||||
/** Variant C: number of PNG image blocks emitted for the history. Folded
|
||||
* into `info.imageCount` too — surfaced separately so dashboards can
|
||||
* attribute image-count growth to history vs system-slab vs reminders. */
|
||||
collapsedImages?: number;
|
||||
/** Variant C: why the history collapse didn't run (or did). Diagnostic
|
||||
* only — see `HistoryCollapseInfo.reason` for the value set. */
|
||||
historyReason?:
|
||||
| 'no_history'
|
||||
| 'prefix_too_short'
|
||||
| 'no_closed_prefix'
|
||||
| 'not_profitable'
|
||||
| 'render_empty'
|
||||
| 'collapsed';
|
||||
}
|
||||
|
||||
// --- helpers ---------------------------------------------------------------
|
||||
@@ -1295,6 +1339,42 @@ export async function transformRequest(
|
||||
|
||||
if (toolsRewritten) req.tools = toolsRewritten;
|
||||
|
||||
// 6. Variant C history-image compression. Runs AFTER all per-message
|
||||
// rewrites so the collapsed prefix reflects final state. Off by default —
|
||||
// round-3 spec marks the savings (~1% per call) as marginal vs the cache-
|
||||
// topology risk. When on, walks messages[] back-to-front tracking open
|
||||
// tool_use_ids; collapses the largest closed-prefix run into one prepended
|
||||
// synthetic user message. Live tail (keepTail turns + anything in an open
|
||||
// tool sequence) stays as text. History image carries NO cache_control on
|
||||
// first ship — the static-slab breakpoint remains the sole pixelpipe
|
||||
// breakpoint until telemetry shows otherwise.
|
||||
if (o.compressHistory && Array.isArray(req.messages) && req.messages.length > 0) {
|
||||
const { messages: newMessages, info: histInfo } = await collapseHistory(
|
||||
req.messages,
|
||||
isCompressionProfitable,
|
||||
{
|
||||
keepTail: o.historyKeepTail,
|
||||
minCollapsePrefix: o.historyMinPrefix,
|
||||
cols: o.cols,
|
||||
},
|
||||
);
|
||||
if (histInfo.collapsedTurns > 0) {
|
||||
req.messages = newMessages;
|
||||
info.collapsedTurns = histInfo.collapsedTurns;
|
||||
info.collapsedChars = histInfo.collapsedChars;
|
||||
info.collapsedImages = histInfo.collapsedImages;
|
||||
info.imageCount += histInfo.collapsedImages;
|
||||
info.imageBytes += histInfo.collapsedImageBytes;
|
||||
info.droppedChars = (info.droppedChars ?? 0) + histInfo.droppedChars;
|
||||
for (const [cp, n] of histInfo.droppedCodepoints) {
|
||||
droppedCodepoints.set(cp, (droppedCodepoints.get(cp) ?? 0) + n);
|
||||
}
|
||||
info.historyReason = 'collapsed';
|
||||
} else if (histInfo.reason) {
|
||||
info.historyReason = histInfo.reason;
|
||||
}
|
||||
}
|
||||
|
||||
info.compressed = true;
|
||||
// Serialize the top dropped codepoints (if any) as `U+HHHH` → count. Cap at
|
||||
// 20 entries — that's enough to identify a misbehaving Unicode block
|
||||
|
||||
+13
-7
@@ -160,14 +160,20 @@ export async function aggregateSessions(
|
||||
if (
|
||||
ev.compressed &&
|
||||
typeof ev.orig_chars === 'number' &&
|
||||
typeof ev.image_bytes === 'number'
|
||||
typeof ev.image_count === 'number'
|
||||
) {
|
||||
const saved = ev.orig_chars - ev.image_bytes;
|
||||
if (saved > 0) {
|
||||
s.charsSaved += saved;
|
||||
// 3.75 chars/token is Anthropic's published rule-of-thumb.
|
||||
s.tokensSavedEst += Math.round(saved / 3.75);
|
||||
}
|
||||
// Honest savings math, mirrors src/dashboard.ts:
|
||||
// textTokens = orig_chars / 4 (Anthropic ~4 chars/token rule)
|
||||
// imageTokens = image_count × 2500 (empirical per-image cost)
|
||||
// tokensSaved = textTokens − imageTokens (CAN be negative when
|
||||
// small per-block compressions cost more than they save)
|
||||
// Previous formula compared chars to PNG bytes (mismatched units) AND
|
||||
// clamped via `if (saved > 0)` which hid the cost-bleed entirely.
|
||||
const textTokens = ev.orig_chars / 4;
|
||||
const imageTokens = ev.image_count * 2500;
|
||||
const tokensSaved = textTokens - imageTokens;
|
||||
s.tokensSavedEst += Math.round(tokensSaved);
|
||||
s.charsSaved += Math.round(tokensSaved * 4);
|
||||
}
|
||||
if (typeof ev.cache_read_tokens === 'number') {
|
||||
s.cacheReadTokens += ev.cache_read_tokens;
|
||||
|
||||
@@ -0,0 +1,467 @@
|
||||
/**
|
||||
* Tests for Variant C history-image compression (task #52).
|
||||
*
|
||||
* Coverage:
|
||||
* - `findClosedPrefixBoundary`: parallel tool calls in one assistant turn,
|
||||
* interleaved tool_use_id close ordering, mid-flight (open) tool_use
|
||||
* straddling the cutoff, and the boring "all closed" case.
|
||||
* - `blocksToText`: drops `thinking` blocks, serialises `tool_use` /
|
||||
* `tool_result` with content, emits `[image]` placeholder for nested
|
||||
* images.
|
||||
* - `messagesToHistoryText`: `--- role ---` framing, skips empty turns.
|
||||
* - `collapseHistory`: full pipeline — opts handling, break-even gate,
|
||||
* synthetic user message shape (text+image+text), live-tail preservation.
|
||||
* - `transformRequest` end-to-end: compressHistory off (default), on with
|
||||
* enough turns to fire, off when no closed prefix exists.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
findClosedPrefixBoundary,
|
||||
blocksToText,
|
||||
messagesToHistoryText,
|
||||
collapseHistory,
|
||||
HISTORY_DEFAULTS,
|
||||
} from '../src/core/history.js';
|
||||
import { transformRequest, isCompressionProfitable } from '../src/core/transform.js';
|
||||
import type { Message } from '../src/core/types.js';
|
||||
|
||||
// A tiny helper so test fixtures are readable.
|
||||
function asst(content: Message['content']): Message {
|
||||
return { role: 'assistant', content };
|
||||
}
|
||||
function usr(content: Message['content']): Message {
|
||||
return { role: 'user', content };
|
||||
}
|
||||
|
||||
describe('findClosedPrefixBoundary', () => {
|
||||
it('returns -1 for empty messages', () => {
|
||||
expect(findClosedPrefixBoundary([], 0)).toBe(-1);
|
||||
expect(findClosedPrefixBoundary([], 10)).toBe(-1);
|
||||
});
|
||||
|
||||
it('returns -1 when cutoff is 0 (no prefix to scan)', () => {
|
||||
const msgs: Message[] = [usr('hi'), asst('hello')];
|
||||
expect(findClosedPrefixBoundary(msgs, 0)).toBe(-1);
|
||||
});
|
||||
|
||||
it('returns last index for an all-plain (no tool calls) conversation', () => {
|
||||
const msgs: Message[] = [usr('hi'), asst('hello'), usr('thanks')];
|
||||
// Cutoff exclusive=3 → scan [0..2]. All plain → all closed. Last index = 2.
|
||||
expect(findClosedPrefixBoundary(msgs, 3)).toBe(2);
|
||||
});
|
||||
|
||||
it('returns the last-closed boundary, not the cutoff itself, when there are mid-flight tool_uses', () => {
|
||||
const msgs: Message[] = [
|
||||
usr('do thing'),
|
||||
asst([{ type: 'tool_use', id: 'A', name: 't', input: {} }]),
|
||||
usr([{ type: 'tool_result', tool_use_id: 'A', content: 'ok' }]),
|
||||
// turn 3 opens 'B' but never closes it within our cutoff.
|
||||
asst([{ type: 'tool_use', id: 'B', name: 't', input: {} }]),
|
||||
usr('plain text'), // no tool_result for B → B is open
|
||||
];
|
||||
// Cutoff exclusive=5 → scan all 5 messages. After msg 2, openSet={}.
|
||||
// After msg 3, openSet={B}. After msg 4 (plain user, no tool_result),
|
||||
// openSet still {B}. Last closed index = 2.
|
||||
expect(findClosedPrefixBoundary(msgs, 5)).toBe(2);
|
||||
});
|
||||
|
||||
it('handles parallel tool calls in one assistant turn', () => {
|
||||
const msgs: Message[] = [
|
||||
usr('parallel work'),
|
||||
asst([
|
||||
{ type: 'tool_use', id: 'A', name: 't', input: {} },
|
||||
{ type: 'tool_use', id: 'B', name: 't', input: {} },
|
||||
]),
|
||||
usr([
|
||||
{ type: 'tool_result', tool_use_id: 'B', content: 'b' },
|
||||
{ type: 'tool_result', tool_use_id: 'A', content: 'a' },
|
||||
]),
|
||||
asst('done'),
|
||||
];
|
||||
// After msg 1: openSet={A,B}. After msg 2: openSet={} (both closed in
|
||||
// reverse order — order doesn't matter for openSet). After msg 3: still
|
||||
// closed. Last index = 3.
|
||||
expect(findClosedPrefixBoundary(msgs, 4)).toBe(3);
|
||||
});
|
||||
|
||||
it('returns -1 when the very first message opens a tool_use that never closes', () => {
|
||||
const msgs: Message[] = [
|
||||
asst([{ type: 'tool_use', id: 'X', name: 't', input: {} }]),
|
||||
usr('plain text, no tool_result'),
|
||||
];
|
||||
// openSet={X} after msg 0 and msg 1 → never closes → no closed index.
|
||||
expect(findClosedPrefixBoundary(msgs, 2)).toBe(-1);
|
||||
});
|
||||
|
||||
it('respects cutoffExclusive — never scans into the live tail', () => {
|
||||
const msgs: Message[] = [
|
||||
usr('q1'),
|
||||
asst('a1'),
|
||||
usr('q2'),
|
||||
asst('a2'),
|
||||
usr('q3'),
|
||||
];
|
||||
// Cutoff exclusive=2 → only scan [0,1]. Last closed = 1.
|
||||
expect(findClosedPrefixBoundary(msgs, 2)).toBe(1);
|
||||
// Cutoff=1 → only scan [0]. Last closed = 0.
|
||||
expect(findClosedPrefixBoundary(msgs, 1)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('blocksToText', () => {
|
||||
it('returns string content verbatim', () => {
|
||||
expect(blocksToText('hello world')).toBe('hello world');
|
||||
});
|
||||
|
||||
it('joins text blocks with double newline', () => {
|
||||
expect(
|
||||
blocksToText([
|
||||
{ type: 'text', text: 'first paragraph' },
|
||||
{ type: 'text', text: 'second paragraph' },
|
||||
]),
|
||||
).toBe('first paragraph\n\nsecond paragraph');
|
||||
});
|
||||
|
||||
it('serialises tool_use with its name and JSON-pretty args', () => {
|
||||
const out = blocksToText([
|
||||
{
|
||||
type: 'tool_use',
|
||||
id: 'tx1',
|
||||
name: 'Read',
|
||||
input: { file_path: '/etc/hosts', limit: 50 },
|
||||
},
|
||||
]);
|
||||
expect(out).toContain('[tool_use Read]');
|
||||
expect(out).toContain('"file_path": "/etc/hosts"');
|
||||
expect(out).toContain('"limit": 50');
|
||||
});
|
||||
|
||||
it('serialises tool_result string content', () => {
|
||||
expect(
|
||||
blocksToText([
|
||||
{ type: 'tool_result', tool_use_id: 'tx1', content: 'output line one' },
|
||||
]),
|
||||
).toContain('[tool_result]\noutput line one');
|
||||
});
|
||||
|
||||
it('marks tool_result is_error with " (error)" suffix', () => {
|
||||
expect(
|
||||
blocksToText([
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'tx1',
|
||||
content: 'oops',
|
||||
is_error: true,
|
||||
},
|
||||
]),
|
||||
).toContain('[tool_result (error)]');
|
||||
});
|
||||
|
||||
it('flattens array-shaped tool_result content (text + image)', () => {
|
||||
const out = blocksToText([
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'tx1',
|
||||
content: [
|
||||
{ type: 'text', text: 'caption' },
|
||||
{
|
||||
type: 'image',
|
||||
source: { type: 'base64', media_type: 'image/png', data: 'AAAA' },
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
expect(out).toContain('caption');
|
||||
expect(out).toContain('[image]');
|
||||
});
|
||||
|
||||
it('drops thinking blocks (Opus 4.7 only requires bit-perfect on most-recent turn)', () => {
|
||||
const out = blocksToText([
|
||||
// @ts-expect-error — `thinking` is a real Anthropic block but not in our
|
||||
// pared-down types union. The blocksToText switch must silently drop it.
|
||||
{ type: 'thinking', thinking: 'secret reasoning trace', signature: 'sig123' },
|
||||
{ type: 'text', text: 'visible answer' },
|
||||
]);
|
||||
expect(out).toBe('visible answer');
|
||||
expect(out).not.toContain('secret reasoning');
|
||||
expect(out).not.toContain('sig123');
|
||||
});
|
||||
|
||||
it('emits [image] placeholder for top-level image blocks', () => {
|
||||
expect(
|
||||
blocksToText([
|
||||
{
|
||||
type: 'image',
|
||||
source: { type: 'base64', media_type: 'image/png', data: 'AAAA' },
|
||||
},
|
||||
]),
|
||||
).toBe('[image]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('messagesToHistoryText', () => {
|
||||
it('frames each turn with --- role --- and joins with blank line', () => {
|
||||
const msgs: Message[] = [usr('hi'), asst('hello')];
|
||||
const out = messagesToHistoryText(msgs, 2);
|
||||
expect(out).toContain('--- user ---\nhi');
|
||||
expect(out).toContain('--- assistant ---\nhello');
|
||||
});
|
||||
|
||||
it('respects upToExclusive (does not include the live tail)', () => {
|
||||
const msgs: Message[] = [usr('q1'), asst('a1'), usr('q2')];
|
||||
const out = messagesToHistoryText(msgs, 2);
|
||||
expect(out).toContain('q1');
|
||||
expect(out).toContain('a1');
|
||||
expect(out).not.toContain('q2');
|
||||
});
|
||||
|
||||
it('skips empty turns', () => {
|
||||
const msgs: Message[] = [
|
||||
usr(''),
|
||||
asst('answer'),
|
||||
usr(' \n '), // whitespace-only
|
||||
];
|
||||
const out = messagesToHistoryText(msgs, 3);
|
||||
expect(out).toContain('--- assistant ---\nanswer');
|
||||
// Only one section header should appear.
|
||||
expect(out.match(/^---/gm)?.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('collapseHistory', () => {
|
||||
// The break-even predicate that ships with transform.ts. We pass it in so
|
||||
// history.ts stays cycle-free.
|
||||
const profitable = isCompressionProfitable;
|
||||
|
||||
it('bails with reason=no_history on empty input', async () => {
|
||||
const { messages, info } = await collapseHistory([], profitable);
|
||||
expect(messages).toEqual([]);
|
||||
expect(info.reason).toBe('no_history');
|
||||
expect(info.collapsedTurns).toBe(0);
|
||||
});
|
||||
|
||||
it('bails with reason=prefix_too_short when the closed prefix < minCollapsePrefix', async () => {
|
||||
const msgs: Message[] = [usr('q1'), asst('a1')];
|
||||
const { messages, info } = await collapseHistory(msgs, profitable, {
|
||||
keepTail: 0,
|
||||
minCollapsePrefix: 5,
|
||||
});
|
||||
expect(messages).toBe(msgs); // unchanged reference
|
||||
expect(info.reason).toBe('prefix_too_short');
|
||||
});
|
||||
|
||||
it('bails with reason=not_profitable when collapsed text is small', async () => {
|
||||
// 12 tiny turns, all plain prose. Each turn ~30 chars → ~400 chars total
|
||||
// serialised. Well under the 10,000-char break-even.
|
||||
const msgs: Message[] = [];
|
||||
for (let i = 0; i < 12; i++) {
|
||||
msgs.push(i % 2 === 0 ? usr(`q${i}`) : asst(`a${i}`));
|
||||
}
|
||||
const { info } = await collapseHistory(msgs, profitable, {
|
||||
keepTail: 0,
|
||||
minCollapsePrefix: 5,
|
||||
});
|
||||
expect(info.reason).toBe('not_profitable');
|
||||
});
|
||||
|
||||
it('collapses a long all-plain conversation into one prepended user message', async () => {
|
||||
// 12 turns, each ~1500 chars → ~18k chars total. Past break-even at cols=100.
|
||||
const msgs: Message[] = [];
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const body = `turn ${i}: ` + 'x'.repeat(2500);
|
||||
msgs.push(i % 2 === 0 ? usr(body) : asst(body));
|
||||
}
|
||||
const { messages: out, info } = await collapseHistory(msgs, profitable, {
|
||||
keepTail: 2,
|
||||
minCollapsePrefix: 5,
|
||||
cols: 100,
|
||||
});
|
||||
expect(info.reason).toBe(undefined); // collapsed → no reason set
|
||||
expect(info.collapsedTurns).toBe(10); // 12 - keepTail(2)
|
||||
expect(info.collapsedImages).toBeGreaterThanOrEqual(1);
|
||||
expect(out.length).toBe(1 + 2); // 1 synthetic + 2 tail turns
|
||||
// Synthetic user message is at index 0
|
||||
expect(out[0]!.role).toBe('user');
|
||||
expect(Array.isArray(out[0]!.content)).toBe(true);
|
||||
const content = out[0]!.content as Array<Record<string, unknown>>;
|
||||
expect(content[0]).toMatchObject({ type: 'text', text: '[Earlier in this conversation:]' });
|
||||
expect(content[content.length - 1]).toMatchObject({
|
||||
type: 'text',
|
||||
text: '[End of earlier context.]',
|
||||
});
|
||||
// Middle blocks are image
|
||||
const imgBlocks = content.filter((c) => c.type === 'image');
|
||||
expect(imgBlocks.length).toBe(info.collapsedImages);
|
||||
// Last 2 turns are the live tail, byte-identical to the original
|
||||
expect(out[1]).toBe(msgs[10]);
|
||||
expect(out[2]).toBe(msgs[11]);
|
||||
});
|
||||
|
||||
it('preserves a tool_use sequence that straddles the live-tail boundary', async () => {
|
||||
// 14 turns: 10 closed turns, then an open tool_use at index 10 that closes at index 12.
|
||||
const msgs: Message[] = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const body = `turn ${i}: ` + 'x'.repeat(2500);
|
||||
msgs.push(i % 2 === 0 ? usr(body) : asst(body));
|
||||
}
|
||||
msgs.push(asst([{ type: 'tool_use', id: 'X', name: 't', input: {} }]));
|
||||
msgs.push(usr([{ type: 'tool_result', tool_use_id: 'X', content: 'r' }]));
|
||||
msgs.push(asst('after the tool')); // index 12, live tail
|
||||
|
||||
// keepTail=1 means cutoff=12. boundary search over [0..11]. msg 10 opens
|
||||
// X, msg 11 closes it → openSet={} after msg 11. But wait, msg 11 is
|
||||
// INSIDE the cutoff. Last-closed = 11. collapseLen = 12.
|
||||
// That means msg 10+11 (the X open/close pair) WOULD be collapsed.
|
||||
// That's fine — they're closed within the prefix.
|
||||
const { messages: out, info } = await collapseHistory(msgs, profitable, {
|
||||
keepTail: 1,
|
||||
minCollapsePrefix: 5,
|
||||
cols: 100,
|
||||
});
|
||||
expect(info.reason).toBe(undefined);
|
||||
expect(info.collapsedTurns).toBe(12);
|
||||
// 1 synthetic prepended + 1 tail msg
|
||||
expect(out.length).toBe(2);
|
||||
expect(out[1]).toBe(msgs[12]); // tail is the post-tool assistant turn
|
||||
});
|
||||
|
||||
it('refuses to collapse when an open tool_use would straddle the live-tail boundary', async () => {
|
||||
// 14 turns: 10 closed turns, then an open tool_use at index 10 that
|
||||
// closes at index 13 — but keepTail=2 puts the close INSIDE the tail.
|
||||
const msgs: Message[] = [];
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const body = `turn ${i}: ` + 'x'.repeat(2500);
|
||||
msgs.push(i % 2 === 0 ? usr(body) : asst(body));
|
||||
}
|
||||
msgs.push(asst([{ type: 'tool_use', id: 'X', name: 't', input: {} }])); // 10
|
||||
msgs.push(usr('thinking out loud')); // 11
|
||||
msgs.push(asst('more thinking')); // 12
|
||||
msgs.push(usr([{ type: 'tool_result', tool_use_id: 'X', content: 'r' }])); // 13
|
||||
|
||||
// keepTail=3 → cutoff=11 → scan [0..10]. After msg 10 openSet={X}.
|
||||
// boundary = last index where openSet was empty = 9.
|
||||
// collapseLen = 10, ≥ minCollapsePrefix(5) → collapses turns 0..9.
|
||||
// openSet={X} from msg 10 onward; that open turn stays in the live tail.
|
||||
const { messages: out, info } = await collapseHistory(msgs, profitable, {
|
||||
keepTail: 3,
|
||||
minCollapsePrefix: 5,
|
||||
cols: 100,
|
||||
});
|
||||
expect(info.reason).toBe(undefined);
|
||||
expect(info.collapsedTurns).toBe(10);
|
||||
// Live tail: msgs 10..13 = 4 messages. Plus 1 prepended.
|
||||
expect(out.length).toBe(5);
|
||||
// X open at out[1] (was msgs[10]), X close at out[4] (was msgs[13]) —
|
||||
// both in the live tail so the tool_use_id linkage survives.
|
||||
expect(out[1]).toBe(msgs[10]);
|
||||
expect(out[4]).toBe(msgs[13]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('transformRequest + compressHistory', () => {
|
||||
function bigPlain(n: number): string {
|
||||
return 'x'.repeat(n);
|
||||
}
|
||||
|
||||
function mkBody(messages: Message[], systemText: string) {
|
||||
return new TextEncoder().encode(
|
||||
JSON.stringify({
|
||||
model: 'claude-3-5-sonnet',
|
||||
system: systemText,
|
||||
messages,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
it('compressHistory:false (default) leaves messages array untouched', async () => {
|
||||
const msgs: Message[] = [];
|
||||
for (let i = 0; i < 12; i++) {
|
||||
msgs.push(i % 2 === 0 ? usr(bigPlain(3000)) : asst(bigPlain(3000)));
|
||||
}
|
||||
const before = JSON.stringify(msgs);
|
||||
const { body, info } = await transformRequest(mkBody(msgs, bigPlain(60_000)));
|
||||
expect(info.collapsedTurns).toBeUndefined();
|
||||
expect(info.historyReason).toBeUndefined();
|
||||
// The returned body's messages count equals the original
|
||||
const reparsed = JSON.parse(new TextDecoder().decode(body));
|
||||
expect(reparsed.messages.length).toBe(msgs.length);
|
||||
expect(JSON.stringify(msgs)).toBe(before); // input not mutated
|
||||
});
|
||||
|
||||
it('compressHistory:true collapses an 8-closed + 2-live conversation', async () => {
|
||||
// 12 turns total. With keepTail=2 + minPrefix=5, we expect 10 turns to
|
||||
// collapse into 1 synthetic prepended user + 2 live tail = 3 total.
|
||||
const msgs: Message[] = [];
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const body = `turn ${i}: ` + bigPlain(2500);
|
||||
msgs.push(i % 2 === 0 ? usr(body) : asst(body));
|
||||
}
|
||||
const { body, info } = await transformRequest(mkBody(msgs, bigPlain(60_000)), {
|
||||
compressHistory: true,
|
||||
historyKeepTail: 2,
|
||||
historyMinPrefix: 5,
|
||||
});
|
||||
expect(info.collapsedTurns).toBe(10);
|
||||
expect(info.collapsedChars).toBeGreaterThan(0);
|
||||
expect(info.collapsedImages).toBeGreaterThanOrEqual(1);
|
||||
expect(info.historyReason).toBe('collapsed');
|
||||
expect(info.imageCount).toBeGreaterThanOrEqual(1 + (info.collapsedImages ?? 0));
|
||||
|
||||
const reparsed = JSON.parse(new TextDecoder().decode(body));
|
||||
expect(reparsed.messages.length).toBe(3); // 1 synthetic + 2 live tail
|
||||
expect(reparsed.messages[0].role).toBe('user');
|
||||
const content = reparsed.messages[0].content;
|
||||
expect(Array.isArray(content)).toBe(true);
|
||||
expect(content[0]).toMatchObject({ type: 'text', text: '[Earlier in this conversation:]' });
|
||||
expect(content[content.length - 1]).toMatchObject({
|
||||
type: 'text',
|
||||
text: '[End of earlier context.]',
|
||||
});
|
||||
});
|
||||
|
||||
it('compressHistory:true sets historyReason when no closed prefix exists', async () => {
|
||||
// First message opens a tool_use; nothing closes it.
|
||||
const msgs: Message[] = [
|
||||
asst([{ type: 'tool_use', id: 'X', name: 't', input: {} }]),
|
||||
usr('plain'),
|
||||
asst('plain'),
|
||||
usr('plain'),
|
||||
];
|
||||
const { info } = await transformRequest(mkBody(msgs, bigPlain(60_000)), {
|
||||
compressHistory: true,
|
||||
historyKeepTail: 1,
|
||||
historyMinPrefix: 2,
|
||||
});
|
||||
expect(info.collapsedTurns).toBeUndefined();
|
||||
expect(info.historyReason).toBe('no_closed_prefix');
|
||||
});
|
||||
|
||||
it('history-image blocks carry NO cache_control (conservative first-cut)', async () => {
|
||||
const msgs: Message[] = [];
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const body = `turn ${i}: ` + bigPlain(2500);
|
||||
msgs.push(i % 2 === 0 ? usr(body) : asst(body));
|
||||
}
|
||||
const { body, info } = await transformRequest(mkBody(msgs, bigPlain(60_000)), {
|
||||
compressHistory: true,
|
||||
historyKeepTail: 2,
|
||||
historyMinPrefix: 5,
|
||||
});
|
||||
expect(info.collapsedImages).toBeGreaterThanOrEqual(1);
|
||||
const reparsed = JSON.parse(new TextDecoder().decode(body));
|
||||
const synth = reparsed.messages[0];
|
||||
const imgs = synth.content.filter((b: { type: string }) => b.type === 'image');
|
||||
for (const img of imgs) {
|
||||
expect(img.cache_control).toBeUndefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('HISTORY_DEFAULTS', () => {
|
||||
it('defaults are conservative (keepTail=4, minCollapsePrefix=10, cols=100)', () => {
|
||||
expect(HISTORY_DEFAULTS.keepTail).toBe(4);
|
||||
expect(HISTORY_DEFAULTS.minCollapsePrefix).toBe(10);
|
||||
expect(HISTORY_DEFAULTS.cols).toBe(100);
|
||||
});
|
||||
});
|
||||
+35
-12
@@ -135,32 +135,55 @@ describe('aggregateSessions', () => {
|
||||
expect(sessions.get('aaaaaaaa')?.requestCount).toBe(2);
|
||||
});
|
||||
|
||||
it('accumulates charsSaved only when compressed and saved>0', async () => {
|
||||
it('accumulates tokens saved using honest formula (allows negative)', async () => {
|
||||
writeEvents(tmp, [
|
||||
// 100,000 chars compressed to 2 images.
|
||||
// textTokens = 100000/4 = 25000
|
||||
// imageTokens = 2 × 2500 = 5000
|
||||
// tokensSaved = +20000 → win
|
||||
ev({
|
||||
first_user_sha8: 'aaaaaaaa',
|
||||
compressed: true,
|
||||
orig_chars: 1000,
|
||||
image_bytes: 200,
|
||||
}), // +800
|
||||
orig_chars: 100_000,
|
||||
image_count: 2,
|
||||
}),
|
||||
// 5,000 chars compressed to 1 image — NET LOSS.
|
||||
// textTokens = 5000/4 = 1250
|
||||
// imageTokens = 1 × 2500 = 2500
|
||||
// tokensSaved = -1250 → loss; must NOT be clamped
|
||||
ev({
|
||||
first_user_sha8: 'aaaaaaaa',
|
||||
compressed: true,
|
||||
orig_chars: 100,
|
||||
image_bytes: 500,
|
||||
}), // negative -> skipped
|
||||
orig_chars: 5_000,
|
||||
image_count: 1,
|
||||
}),
|
||||
// Not compressed at all — skipped.
|
||||
ev({
|
||||
first_user_sha8: 'aaaaaaaa',
|
||||
compressed: false,
|
||||
orig_chars: 1000,
|
||||
image_bytes: 0,
|
||||
}), // not compressed -> skipped
|
||||
image_count: 0,
|
||||
}),
|
||||
]);
|
||||
const { sessions } = await aggregateSessions(tmp);
|
||||
const s = sessions.get('aaaaaaaa')!;
|
||||
expect(s.charsSaved).toBe(800);
|
||||
// 800 / 3.75 ≈ 213 — round-trip through Math.round.
|
||||
expect(s.tokensSavedEst).toBe(Math.round(800 / 3.75));
|
||||
// 20000 + (-1250) = 18750 tokens net.
|
||||
expect(s.tokensSavedEst).toBe(18_750);
|
||||
expect(s.charsSaved).toBe(18_750 * 4);
|
||||
});
|
||||
|
||||
it('reports negative tokensSavedEst when all compressions net-lose', async () => {
|
||||
writeEvents(tmp, [
|
||||
// 3 small blocks each net-lose ~1250 tokens.
|
||||
ev({ first_user_sha8: 'bbbbbbbb', compressed: true, orig_chars: 5000, image_count: 1 }),
|
||||
ev({ first_user_sha8: 'bbbbbbbb', compressed: true, orig_chars: 5000, image_count: 1 }),
|
||||
ev({ first_user_sha8: 'bbbbbbbb', compressed: true, orig_chars: 5000, image_count: 1 }),
|
||||
]);
|
||||
const { sessions } = await aggregateSessions(tmp);
|
||||
const s = sessions.get('bbbbbbbb')!;
|
||||
// 3 × (1250 - 2500) = -3750 tokens.
|
||||
expect(s.tokensSavedEst).toBe(-3750);
|
||||
expect(s.charsSaved).toBe(-15_000);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user