From 6f3ca21ab5bd66f93bf5b92c9f1e9e79ef908f26 Mon Sep 17 00:00:00 2001 From: Beatrice0377 <2296143245@qq.com> Date: Thu, 20 Aug 2026 10:40:32 +0800 Subject: [PATCH] fix: align nudge summary token counts --- src/compress.ts | 28 ++++++++++--- src/nudge-text.ts | 9 ++-- src/types.ts | 13 ++++++ tests/nudge-text.test.ts | 55 ++++++++++++++++++++++++- tests/nudge.test.ts | 89 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 184 insertions(+), 10 deletions(-) diff --git a/src/compress.ts b/src/compress.ts index ac75a9d..8ae456c 100644 --- a/src/compress.ts +++ b/src/compress.ts @@ -47,6 +47,7 @@ import type { ProcessTurnResult, Recommendation, StatusReport, + TierTargetBlockStat, } from "./types.js"; export interface Ports { @@ -913,24 +914,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 { - const out: Record = {}; +): Record { + const out: Record = {}; 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; } @@ -1093,6 +1110,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: { diff --git a/src/nudge-text.ts b/src/nudge-text.ts index 719e77a..c8a768e 100644 --- a/src/nudge-text.ts +++ b/src/nudge-text.ts @@ -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"; @@ -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}`; }); @@ -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"; diff --git a/src/types.ts b/src/types.ts index bd2af68..c512268 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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) means adapters * that read e.g. emergencyOverride get a compile-time signal if a key is diff --git a/tests/nudge-text.test.ts b/tests/nudge-text.test.ts index b512531..b7e7c7c 100644 --- a/tests/nudge-text.test.ts +++ b/tests/nudge-text.test.ts @@ -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) => ({ @@ -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 { + 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"); +}); diff --git a/tests/nudge.test.ts b/tests/nudge.test.ts index 3edd218..b29cdcd 100644 --- a/tests/nudge.test.ts +++ b/tests/nudge.test.ts @@ -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 { @@ -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", + ); +});