Skip to content
Merged
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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
35 changes: 27 additions & 8 deletions src/compress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1245,22 +1256,21 @@ 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 =
lastShown === 0 || tokenCount - lastShown >= growthFloor;
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;
Expand All @@ -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)}%`;
}
Expand All @@ -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
Expand Down
28 changes: 28 additions & 0 deletions tests/nudge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down
Loading