From 8f2a67dde4bc1c97a276bc81e9acfac5b8870d62 Mon Sep 17 00:00:00 2001 From: ranxianglei Date: Wed, 9 Sep 2026 22:53:03 +0800 Subject: [PATCH] fix: usage-gate count-triggered tier nudges (#237) Count-based tier distillation (tier2Trigger/tier3Trigger) fired at any usage once enough blocks accumulated. Below the nudge usage band there is no need yet, and the injected suggestion made repetition-prone models flail against the tier3-rewrite guard. Gate the count paths behind nudge.minContextLimitPct (the same band the first-sight bypass uses); token-mass paths (>= 1.5x threshold) stay ungated. The idle reason now labels gated tiers as 'usage-gated' so acp_status explains the silence. --- README.md | 2 +- src/compress.ts | 35 +++++++++++++++++++++++++++-------- tests/nudge.test.ts | 28 ++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 8f3d5f9..5452a48 100644 --- a/README.md +++ b/README.md @@ -111,7 +111,7 @@ The nudge system tells the model *when* to compress. It implements: - **Threshold gate**: fires when context usage ≥ `nudge.minContextLimitPct`. - **Growth-gating**: a repeat nudge requires positive growth since the baseline (prevents re-firing every turn). `"strong"` force relaxes this. -- **Tier-distillation triggers**: when active tier-1 blocks pile up past `tiers.tier2Trigger`, emit a tier-2 distillation nudge; tier-3 analogously. +- **Tier-distillation triggers**: when active tier-1 blocks pile up past `tiers.tier2Trigger`, emit a tier-2 distillation nudge; tier-3 analogously. Count-triggered distillation is gated to the nudge usage band (`nudge.minContextLimitPct`): below it, block counts alone don't justify burning a turn (#237). - **Compressible-range computation**: reports the actual compressible ranges (excluding covered + preserved-recent messages) so the model knows what to target. - **Baseline reset on compress**: `applyCompression` clears the growth baseline on success, preventing the feedback-loop bug where the nudge re-fires post-compress. diff --git a/src/compress.ts b/src/compress.ts index 51769ae..9fdbeef 100644 --- a/src/compress.ts +++ b/src/compress.ts @@ -1209,6 +1209,17 @@ function decideNudge(input: NudgeInput): NudgeDecision { const t2Count = tiers[2]?.targetBlocks.length ?? 0; const t3Count = tiers[3]?.targetBlocks.length ?? 0; + // Count-triggered tier distillation is usage-gated (#237): block COUNT is + // a mass proxy for ~10:1-condensed summaries, so the token gates under-rate + // it — but below the nudge usage band there is no NEED yet, and firing on + // 5 tiny blocks at low usage just burns a model turn (and repetition-prone + // models flail against the #3 guard on the suggested rewrite). Token-mass + // paths (>= 1.5x threshold) stay ungated: crossing them is need by itself. + const tierCountUsageFloor = config.nudge.minContextLimitPct; + const t2CountReady = + t2Count >= config.tiers.tier2Trigger && usage >= tierCountUsageFloor; + const t3CountReady = + t3Count >= config.tiers.tier3Trigger && usage >= tierCountUsageFloor; if (pressure) { // High pressure: pick the tier with the MAX pending so pressure can route // to distillation when that reclaims the most tokens. Gated on effective @@ -1245,8 +1256,7 @@ function decideNudge(input: NudgeInput): NudgeDecision { injectedReason = `T1 effective ${t1Eff} >= ${nudgeGrowthTokens}, growth ${growthSinceReference}, usage ${Math.round(usage * 100)}%`; } else if ( config.tiers.enabled && - (t2Count >= config.tiers.tier2Trigger || - (t2Pen >= tier2Threshold && t2Pen > t1Eff)) + (t2CountReady || (t2Pen >= tier2Threshold && t2Pen > t1Eff)) ) { const lastShown = state.nudge.lastShownByTier[2] ?? 0; const cadenceMet = @@ -1254,13 +1264,13 @@ function decideNudge(input: NudgeInput): NudgeDecision { if (cadenceMet) { injectedTier = 2; injectedReason = - t2Count >= config.tiers.tier2Trigger + t2CountReady ? `T2 distill ready: ${t2Count} tier-1 blocks >= tier2Trigger ${config.tiers.tier2Trigger} (${t2Pen} tokens), usage ${Math.round(usage * 100)}%` : `T2 distill ready: ${tiers[2]!.targetBlocks.length} tier-1 blocks (${t2Pen} tokens) >= ${tier2Threshold} (1.5x) and > T1 effective ${t1Eff}, usage ${Math.round(usage * 100)}%`; } } else if ( config.tiers.enabled && - (t3Count >= config.tiers.tier3Trigger || + (t3CountReady || (t3Pen >= tier2Threshold && t3Pen > t2Pen && t3Pen > t1Eff)) ) { const lastShown = state.nudge.lastShownByTier[3] ?? 0; @@ -1269,7 +1279,7 @@ function decideNudge(input: NudgeInput): NudgeDecision { if (cadenceMet) { injectedTier = 3; injectedReason = - t3Count >= config.tiers.tier3Trigger + t3CountReady ? `T3 condense ready: ${t3Count} tier-2 blocks >= tier3Trigger ${config.tiers.tier3Trigger} (${t3Pen} tokens), usage ${Math.round(usage * 100)}%` : `T3 condense ready: ${tiers[3]!.targetBlocks.length} tier-2 blocks (${t3Pen} tokens) >= ${tier2Threshold} (1.5x) and > T2 ${t2Pen} and > T1 effective ${t1Eff}, usage ${Math.round(usage * 100)}%`; } @@ -1293,18 +1303,27 @@ function decideNudge(input: NudgeInput): NudgeDecision { } else { const tiersList = [1, 2, 3] as const; const eligible = tiersList.filter((t) => config.tiers.enabled || t === 1); - const countReady = (t: 1 | 2 | 3) => + const countReadyUngated = (t: 1 | 2 | 3) => t === 2 ? t2Count >= config.tiers.tier2Trigger : t === 3 ? t3Count >= config.tiers.tier3Trigger : false; + const countReady = (t: 1 | 2 | 3) => + countReadyUngated(t) && usage >= tierCountUsageFloor; const ready = eligible .filter((t) => (tiers[t]?.pending ?? 0) >= nudgeGrowthTokens) .map((t) => `T${t} ${tiers[t]!.pending}`); const readyCount = eligible - .filter((t) => (tiers[t]?.pending ?? 0) < nudgeGrowthTokens && countReady(t)) - .map((t) => `T${t} ${t === 2 ? t2Count : t3Count} blocks (count)`); + .filter( + (t) => (tiers[t]?.pending ?? 0) < nudgeGrowthTokens && countReadyUngated(t), + ) + .map( + (t) => + `T${t} ${t === 2 ? t2Count : t3Count} blocks (count${ + usage >= tierCountUsageFloor ? "" : ", usage-gated" + })`, + ); const readyAll = [...ready, ...readyCount]; const readyHint = readyAll.length > 0 ? `, ready: ${readyAll.join(", ")}` : ""; const blocked = eligible diff --git a/tests/nudge.test.ts b/tests/nudge.test.ts index a9e932a..32e3c6b 100644 --- a/tests/nudge.test.ts +++ b/tests/nudge.test.ts @@ -636,6 +636,34 @@ test("re-baseline after a tokenCount scale drop also resets per-tier cadence sta assert.deepEqual(stamped.lastShownByTier, {}, "per-tier cadence stamps must not survive a scale drop"); }); +test("arbitration: count-triggered T2 stays silent below the nudge usage band (#237)", () => { + const core = createCore(); + const config = buildConfig({ preserveRecentMessages: 30 }); + const messages = makeMessages(30); + let state = core.processTurn({ messages, state: createInitialState(), config, tokenCount: 50_000 }).state; + state = { ...state, blocks: t1Blocks([["m1"], ["m2"], ["m3"], ["m4"], ["m5"]], 400) }; + // 40k / 100k = 40% < minContextLimitPct 45%: count-ready mass must not inject + const turn = core.processTurn({ messages, state, config, tokenCount: 40_000 }); + assert.equal(turn.nudge.shouldInject, false, `reason: ${turn.nudge.reason}`); + assert.doesNotMatch(turn.nudge.reason ?? "", /T2 distill ready/); + assert.match(turn.nudge.reason ?? "", /T2 5 blocks \(count, usage-gated\)/); +}); + +test("arbitration: count-triggered T3 stays silent below the nudge usage band (#237)", () => { + const core = createCore(); + const config = buildConfig({ tiers: { enabled: true, tier2Trigger: 2, tier3Trigger: 3 }, preserveRecentMessages: 30 }); + const messages = makeMessages(30); + let state = core.processTurn({ messages, state: createInitialState(), config, tokenCount: 50_000 }).state; + state = { + ...state, + blocks: t1Blocks([["m1"], ["m2"], ["m3"], ["m4"], ["m5"]], 400).map((b) => ({ ...b, tier: 2 })), + }; + const turn = core.processTurn({ messages, state, config, tokenCount: 40_000 }); + assert.equal(turn.nudge.shouldInject, false, `reason: ${turn.nudge.reason}`); + assert.doesNotMatch(turn.nudge.reason ?? "", /T3 condense ready/); + assert.match(turn.nudge.reason ?? "", /T3 5 blocks \(count, usage-gated\)/); +}); + test("arbitration: T2 fires on tier-1 block COUNT (tier2Trigger) even when summary tokens are small", () => { const core = createCore(); const config = buildConfig({ preserveRecentMessages: 30 });