Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 23 additions & 5 deletions src/compress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import type {
ProcessTurnResult,
Recommendation,
StatusReport,
TierTargetBlockStat,
} from "./types.js";

export interface Ports {
Expand Down Expand Up @@ -935,24 +936,40 @@ function resolveAdaptiveGrowth(
* atomically reject — see CompressibleRange.chars); T2 = total summary
* tokens of all active tier-1 blocks; T3 = total summary tokens of all
* active tier-2 blocks. */
interface TierPending {
pending: number;
targetBlocks: CompressionBlock[];
targetBlockStats: TierTargetBlockStat[];
}

function pendingByTier(
state: CompressionState,
recommendation: Recommendation | undefined,
countTokens: (t: string) => number,
minCompressRange: number,
): Record<number, { pending: number; targetBlocks: CompressionBlock[] }> {
const out: Record<number, { pending: number; targetBlocks: CompressionBlock[] }> = {};
): Record<number, TierPending> {
const out: Record<number, TierPending> = {};
const merged = recommendation?.recommendedRanges ?? [];
const effective =
minCompressRange > 0
? merged.filter((r) => (r.chars ?? r.tokens * 4) >= minCompressRange)
: merged;
out[1] = { pending: effective.reduce((s, r) => s + r.tokens, 0), targetBlocks: [] };
out[1] = { pending: effective.reduce((s, r) => s + r.tokens, 0), targetBlocks: [], targetBlockStats: [] };
const active = activeBlocks(state);
const t1 = active.filter((b) => b.tier === 1);
const t2 = active.filter((b) => b.tier === 2);
out[2] = { pending: t1.reduce((s, b) => s + countTokens(b.summary), 0), targetBlocks: t1 };
out[3] = { pending: t2.reduce((s, b) => s + countTokens(b.summary), 0), targetBlocks: t2 };
const t1Stats = t1.map((b) => ({ blockId: b.blockId, summaryTokens: countTokens(b.summary) }));
const t2Stats = t2.map((b) => ({ blockId: b.blockId, summaryTokens: countTokens(b.summary) }));
out[2] = {
pending: t1Stats.reduce((s, st) => s + st.summaryTokens, 0),
targetBlocks: t1,
targetBlockStats: t1Stats,
};
out[3] = {
pending: t2Stats.reduce((s, st) => s + st.summaryTokens, 0),
targetBlocks: t2,
targetBlockStats: t2Stats,
};
return out;
}

Expand Down Expand Up @@ -1115,6 +1132,7 @@ function decideNudge(input: NudgeInput): NudgeDecision {
compressibleRanges: rec?.recommendedRanges ?? [],
protectedRanges: rec?.contextRanges.protected ?? [],
tierTargetBlocks: injectedTier ? tiers[injectedTier]!.targetBlocks : [],
tierTargetBlockStats: injectedTier ? tiers[injectedTier]!.targetBlockStats : [],
contextUsage: usage,
tier: injectedTier,
breakdown: {
Expand Down
9 changes: 5 additions & 4 deletions src/nudge-text.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { NudgeDecision, CompressibleRange, ProtectedRange, ContextBreakdown, CompressionBlock } from "./types.js";
import type { NudgeDecision, CompressibleRange, ProtectedRange, ContextBreakdown, CompressionBlock, TierTargetBlockStat } from "./types.js";
import { defaultPrompts } from "./prompts.js";
import type { Prompts } from "./prompts.js";

Expand Down Expand Up @@ -36,12 +36,13 @@ function formatBreakdown(bd?: ContextBreakdown): string {



function formatTierTargetBlocks(blocks: CompressionBlock[]): string {
function formatTierTargetBlocks(blocks: CompressionBlock[], stats: TierTargetBlockStat[] = []): string {
if (blocks.length === 0) {
return "Target blocks: (none — no tier blocks found)";
}
const statByBlock = new Map(stats.map((s) => [s.blockId, s.summaryTokens]));
const lines = blocks.map((b) => {
const summaryTokens = Math.ceil((b.summary ?? "").length / 4);
const summaryTokens = statByBlock.get(b.blockId) ?? Math.ceil((b.summary ?? "").length / 4);
const topic = b.topic ? ` "${b.topic}"` : "";
return ` ${b.blockId} ${b.effectiveMessageIds.length} msgs ${formatK(b.compressedTokens)}→${formatK(summaryTokens)}${topic}`;
});
Expand Down Expand Up @@ -128,7 +129,7 @@ export function renderNudgeText(decision: NudgeDecision, prompts: Prompts = defa
if (decision.tier !== null && decision.tier >= 2) {
const isT2 = decision.tier === 2;
const targets = decision.tierTargetBlocks ?? [];
const blockList = formatTierTargetBlocks(targets);
const blockList = formatTierTargetBlocks(targets, decision.tierTargetBlockStats ?? []);
const startId = targets[0]?.blockId ?? "b1";
const endId = targets[targets.length - 1]?.blockId ?? "b5";
const voice: NudgeVoice = isEmergency ? "emergency" : "gentle";
Expand Down
13 changes: 13 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,12 +210,25 @@ export interface NudgeDecision {
/** When `tier` is set, the active lower-tier blocks that should be distilled
* into a single higher-tier block. Empty when no tier nudge. */
tierTargetBlocks?: CompressionBlock[];
/** Per-block summary token counts for `tierTargetBlocks`, precomputed by the
* decision layer with the active countTokens so the renderer never re-estimates.
* Absent for hand-built decisions; renderers then keep the legacy estimate. */
tierTargetBlockStats?: TierTargetBlockStat[];
contextUsage: number;
tier: CompressionTier | null;
breakdown: NudgeBreakdown;
contextBreakdown?: ContextBreakdown;
}

/** Derived token accounting for one tier-target block, computed once in the
* decision layer and consumed as-is by renderers. Not persisted on
* CompressionBlock: countTokens is host-injected and may change between
* sessions, so storing it on the block would go stale. */
export interface TierTargetBlockStat {
blockId: string;
summaryTokens: number;
}

/** Numeric debug/reason fields exposed alongside a nudge decision. Keeping
* these typed (rather than a bare Record<string, number>) means adapters
* that read e.g. emergencyOverride get a compile-time signal if a key is
Expand Down
55 changes: 54 additions & 1 deletion tests/nudge-text.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { renderNudgeText } from "../src/nudge-text.js";
import type { NudgeDecision, CompressibleRange } from "../src/types.js";
import type { NudgeDecision, CompressibleRange, CompressionBlock } from "../src/types.js";

function makeRanges(count: number): CompressibleRange[] {
return Array.from({ length: count }, (_, i) => ({
Expand Down Expand Up @@ -174,3 +174,56 @@ test("over-limit renders with emergency voice (MAJOR-2 fix)", () => {
assert.equal(result.voice, "emergency", "over-limit should use emergency voice, not gentle");
assert.ok(!result.text.includes("not an overflow warning"), "should NOT contain gentle reassurance");
});

function makeBlock(overrides: Partial<CompressionBlock> = {}): CompressionBlock {
return {
blockId: "b1",
runId: "r1",
tier: 1 as const,
summary: "s".repeat(10000), // length/4 = 2500
directMessageIds: ["m00001"],
effectiveMessageIds: ["m00001"],
directBlockIds: [],
compressedTokens: 5000,
createdAt: Date.now(),
survivedCount: 0,
generation: "young" as const,
active: true,
...overrides,
};
}

test("tier-2 renderer uses precomputed tierTargetBlockStats, not length/4 (issue #45)", () => {
const result = renderNudgeText(
makeDecision({
tier: 2,
tierTargetBlocks: [makeBlock()],
tierTargetBlockStats: [{ blockId: "b1", summaryTokens: 12345 }],
}),
);
assert.ok(result.text.includes("12.3K"), "should display the precomputed summary token count");
assert.ok(!result.text.includes("2.5K"), "must NOT fall back to length/4 when stats are present");
});

test("tier-2 renderer without stats keeps legacy length/4 behavior (compat)", () => {
const result = renderNudgeText(
makeDecision({ tier: 2, tierTargetBlocks: [makeBlock()] }),
);
assert.ok(result.text.includes("2.5K"), "legacy hand-built decisions still estimate via length/4");
});

test("tier-2 ASCII output unchanged when precomputed stats match the legacy estimate", () => {
const block = makeBlock();
const legacy = renderNudgeText(
makeDecision({ tier: 2, tierTargetBlocks: [block] }),
);
const withStats = renderNudgeText(
makeDecision({
tier: 2,
tierTargetBlocks: [block],
tierTargetBlockStats: [{ blockId: "b1", summaryTokens: 2500 }],
}),
);
assert.equal(withStats.text, legacy.text, "precomputed stats equal to the legacy estimate must not alter output");
assert.equal(withStats.voice, legacy.voice, "voice must be identical as well");
});
89 changes: 89 additions & 0 deletions tests/nudge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { test } from "node:test";
import assert from "node:assert/strict";
import { createCore } from "../src/compress.js";
import { createInitialState } from "../src/state.js";
import { renderNudgeText } from "../src/nudge-text.js";
import { defaultCountTokens } from "../src/tokenize.js";
import type { Config, CoreMessage } from "../src/types.js";

function buildConfig(overrides: Partial<Config> = {}): Config {
Expand Down Expand Up @@ -635,3 +637,90 @@ test("re-baseline after a tokenCount scale drop also resets per-tier cadence sta
assert.equal(stamped.lastNudgeShownTokens, 0, "shared cadence baseline cleared");
assert.deepEqual(stamped.lastShownByTier, {}, "per-tier cadence stamps must not survive a scale drop");
});

function cjkBlocks(count: number, summary: string) {
return Array.from({ length: count }, (_, i) => ({
blockId: `b${i + 1}`,
runId: "r1",
tier: 1 as const,
summary,
directMessageIds: [`m${i}`],
effectiveMessageIds: [`m${i}`],
directBlockIds: [],
compressedTokens: 5000,
createdAt: Date.now(),
survivedCount: 0,
generation: "young" as const,
active: true,
}));
}

test("issue #45: custom tokenizer stats flow from decision to renderer (fake-fix guard)", () => {
// countTokens deliberately differs from BOTH length/4 and defaultCountTokens.
// If the fix were merely "renderer switches to defaultCountTokens", the
// renderer output would not match the decision-layer stats here.
const custom = (t: string) => Math.ceil(t.length / 2);
const core = createCore({ countTokens: custom });
const config = buildConfig({
compress: { minCompressRange: 5000, maxSummaryLength: 0, minSummaryLength: 0 },
preserveRecentMessages: 10,
});
const messages = makeMessages(10);
let state = createInitialState();
state = core.processTurn({ messages, state, config, tokenCount: 50000 }).state;
// 13200 chars incl. CJK period: custom = 6600, length/4 = 3300, defaultCountTokens = 12300.
const summary = "汉字摘要记录压缩范围。".repeat(1200);
state = { ...state, blocks: cjkBlocks(3, summary) };
const turn = core.processTurn({ messages, state, config, tokenCount: 60000 });
assert.equal(turn.nudge.shouldInject, true);
assert.equal(turn.nudge.tier, 2, "3 blocks x 6600 = 19800 >= 9000 (1.5x) and > T1 effective 0");
const stats = turn.nudge.tierTargetBlockStats!;
assert.equal(stats.length, 3, "one stat per target block");
for (const s of stats) {
assert.equal(s.summaryTokens, custom(summary), "stat matches the injected custom tokenizer");
}
assert.equal(
turn.nudge.breakdown.pendingT2,
stats.reduce((a, s) => a + s.summaryTokens, 0),
"pendingT2 aggregates the same per-block stats",
);
const rendered = renderNudgeText(turn.nudge).text;
assert.match(rendered, /b1\s+1 msgs\s+5\.0K→6\.6K/, "renderer shows the custom-tokenizer value (6600)");
assert.ok(!rendered.includes("3.3K"), "renderer must NOT show length/4 (3300)");
assert.ok(!rendered.includes("12.3K"), "renderer must NOT show defaultCountTokens (12300)");
});

test("issue #45: CJK summaries render with CJK-aware token values, not length/4", () => {
const core = createCore();
const config = buildConfig({
compress: { minCompressRange: 5000, maxSummaryLength: 0, minSummaryLength: 0 },
preserveRecentMessages: 10,
});
const messages = makeMessages(10);
let state = createInitialState();
state = core.processTurn({ messages, state, config, tokenCount: 50000 }).state;
// Pure-CJK summary: defaultCountTokens == length (1 CJK char = 1 token);
// length/4 stays ~4x lower, which is the issue #45 divergence.
const summary = "汉字摘要记录了压缩范围的完整内容包含关键决策文件路径与错误信息".repeat(200);
state = { ...state, blocks: cjkBlocks(3, summary) };
const turn = core.processTurn({ messages, state, config, tokenCount: 60000 });
assert.equal(turn.nudge.shouldInject, true);
assert.equal(turn.nudge.tier, 2);
const stats = turn.nudge.tierTargetBlockStats!;
assert.equal(stats.length, 3);
const expected = defaultCountTokens(summary);
for (const s of stats) {
assert.equal(s.summaryTokens, expected, "stat matches the CJK-aware defaultCountTokens");
}
assert.equal(turn.nudge.breakdown.pendingT2, stats.reduce((a, s) => a + s.summaryTokens, 0));
const rendered = renderNudgeText(turn.nudge).text;
assert.match(
rendered,
new RegExp(`b1\\s+1 msgs\\s+5\\.0K→${(expected / 1000).toFixed(1)}K`),
"renderer shows the CJK-aware value",
);
assert.ok(
!rendered.includes(`${(Math.ceil(summary.length / 4) / 1000).toFixed(1)}K`),
"renderer must NOT show length/4",
);
});
Loading