From e4311c01abb159a283c9213f9ec8a56d7436b452 Mon Sep 17 00:00:00 2001 From: akihidem Date: Tue, 12 May 2026 00:35:30 +0900 Subject: [PATCH] =?UTF-8?q?feat(snowball):=20baseline=20=E5=B7=AE=E3=81=97?= =?UTF-8?q?=E5=BC=95=E3=81=8D=20+=20=E6=9C=80=E5=B0=8F=E3=82=BF=E3=83=BC?= =?UTF-8?q?=E3=83=B3=E6=95=B0=E3=82=B2=E3=83=BC=E3=83=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SessionStart フックや MEMORY.md 自動注入で起動直後から数百 k token を 消費する環境では、従来の単純な cumulativeUncached 閾値判定だと 1 ターン目から snowball_detected が誤発火していた。 - 最初の assistant サンプルの cache_creation を baseline として控除し 「成長分」を閾値と比較する - snowballMinTurns(既定 3)未満なら閾値超でも triggered=false - SnowballState に baselineTokens / turns / minTurns を追加(観測性向上) - detectSnowball のユニットテストを新規追加(5 ケース) Co-Authored-By: Claude Opus 4.7 (1M context) --- src/config.ts | 5 +++ src/infer/snowball.ts | 38 +++++++++++++++++++--- src/mcp/tools.ts | 6 +++- src/watch.ts | 6 +++- tests/snowball.test.ts | 73 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 122 insertions(+), 6 deletions(-) create mode 100644 tests/snowball.test.ts diff --git a/src/config.ts b/src/config.ts index d3ebf74..13ac427 100644 --- a/src/config.ts +++ b/src/config.ts @@ -27,6 +27,8 @@ export type CogsyncConfig = { }; thresholds: { snowballToken: number; + /** snowball 発火に必要な最小ターン数(heavy-context 起動時の誤発火抑制) */ + snowballMinTurns: number; limitWarnMin: number; aiWaitBreakMin: number; /** アクティブ判定: 最新の user/assistant が直近 N 分以内ならアクティブ(top session 揺らぎ抑制) */ @@ -58,6 +60,9 @@ export const DEFAULT_CONFIG: CogsyncConfig = { // 80k だと 20% のセッションが triggered で多すぎる。 // 150k (≈ p90 強) で 約 12% に絞り、本当に Lost-in-the-middle 圏のものを通知。 snowballToken: 150_000, + // SessionStart フックや MEMORY.md 注入で初手から 150k を超える環境が + // あるため、最低 3 ターン進むまで snowball を抑止する。 + snowballMinTurns: 3, limitWarnMin: 15, /** ai_busy がこの分以上続いたらブレイク提案(CO-5) */ aiWaitBreakMin: 5, diff --git a/src/infer/snowball.ts b/src/infer/snowball.ts index 22c68db..4d6fd4a 100644 --- a/src/infer/snowball.ts +++ b/src/infer/snowball.ts @@ -4,6 +4,12 @@ * 同一セッション内の累積トークン (input + cache_creation + output) が * 閾値を超えたら triggered。cache_read は除く(再利用なので「膨張」ではない)。 * + * セッション初手の cache_creation には SessionStart フックや CLAUDE.md / + * MEMORY.md / system prompt の baseline 注入が含まれる。これは「初期コスト」 + * であって snowball ではないので、最初のサンプルの cacheCreation を baseline + * として差し引く。さらに、ターン数が minTurns 未満のうちは実作業が浅すぎる + * ので triggered を抑止する(heavy-context 起動時の誤発火を防ぐ)。 + * * 過去 30 日のバックテストで適切な閾値を再調整する余地あり * (scripts/backtest-snowball.ts 参照)。 */ @@ -12,33 +18,54 @@ import type { SessionTokenSample } from "../observers/claude_code.ts"; export type SnowballState = { triggered: boolean; + /** baseline 差し引き後の「成長分」累積。表示・通知もこれ。 */ cumulativeTokens: number; + /** 比較対象の閾値 */ threshold: number; + /** SessionStart 注入などで差し引いた baseline (最初の cache_creation) */ + baselineTokens: number; + /** 現在のターン数(assistant サンプル数) */ + turns: number; + /** 最小ターン数(これ未満なら triggered=false) */ + minTurns: number; /** triggered になった瞬間のサンプル時刻 */ triggeredAt: Date | null; /** 最新サンプルの ts */ latestAt: Date | null; }; -export function detectSnowball(samples: SessionTokenSample[], threshold: number): SnowballState { +export function detectSnowball( + samples: SessionTokenSample[], + threshold: number, + minTurns: number = 3, +): SnowballState { if (samples.length === 0) { return { triggered: false, cumulativeTokens: 0, threshold, + baselineTokens: 0, + turns: 0, + minTurns, triggeredAt: null, latestAt: null, }; } + // 最初のサンプルの cache_creation を baseline として差し引く。 + // これにより「成長分」だけが snowball の物差しになる。 + const baseline = samples[0]!.tokens.cacheCreation; const latest = samples[samples.length - 1]!; - const triggered = latest.cumulativeUncached >= threshold; + const growth = Math.max(0, latest.cumulativeUncached - baseline); + const turns = samples.length; + const triggered = growth >= threshold && turns >= minTurns; let triggeredAt: Date | null = null; if (triggered) { // 初めて threshold を超えたサンプルを探す(前後 30 件まで線形) const start = Math.max(0, samples.length - 30); for (let i = start; i < samples.length; i++) { - if (samples[i]!.cumulativeUncached >= threshold) { + const g = Math.max(0, samples[i]!.cumulativeUncached - baseline); + if (g >= threshold) { triggeredAt = samples[i]!.ts; break; } @@ -47,8 +74,11 @@ export function detectSnowball(samples: SessionTokenSample[], threshold: number) return { triggered, - cumulativeTokens: latest.cumulativeUncached, + cumulativeTokens: growth, threshold, + baselineTokens: baseline, + turns, + minTurns, triggeredAt, latestAt: latest.ts, }; diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index e2696d9..be1a824 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -130,7 +130,11 @@ async function buildAdviseInput(ctx: ResourceContext): Promise { // 2. アクティブセッション → snowball, workState const sessionInfo = safeReadLatestSession(config); const snowball = sessionInfo - ? detectSnowball(readSessionSamples(sessionInfo.file), config.thresholds.snowballToken) + ? detectSnowball( + readSessionSamples(sessionInfo.file), + config.thresholds.snowballToken, + config.thresholds.snowballMinTurns, + ) : null; const ws = sessionInfo ? classifyWorkState(sessionInfo.lastUserAt, sessionInfo.lastAssistantAt, now) diff --git a/src/watch.ts b/src/watch.ts index 7f4d6c8..feaa624 100644 --- a/src/watch.ts +++ b/src/watch.ts @@ -119,7 +119,11 @@ async function tick(ctx: Ctx, aiBusySince: Date | null): Promise<{ aiBusySince: const sessionInfo = safeReadLatestSession(config); const snowball = sessionInfo - ? detectSnowball(readSessionSamples(sessionInfo.file), config.thresholds.snowballToken) + ? detectSnowball( + readSessionSamples(sessionInfo.file), + config.thresholds.snowballToken, + config.thresholds.snowballMinTurns, + ) : null; const now = new Date(); diff --git a/tests/snowball.test.ts b/tests/snowball.test.ts new file mode 100644 index 0000000..143956a --- /dev/null +++ b/tests/snowball.test.ts @@ -0,0 +1,73 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { detectSnowball } from "../src/infer/snowball.ts"; +import type { SessionTokenSample } from "../src/observers/claude_code.ts"; + +function sample( + cumulative: number, + cacheCreation: number, + ts = new Date("2026-05-12T00:00:00Z"), +): SessionTokenSample { + return { + sessionId: "t", + ts, + cumulativeUncached: cumulative, + tokens: { input: 0, output: 0, cacheCreation, cacheRead: 0 }, + model: "claude-opus-4-7", + }; +} + +test("detectSnowball: 空入力は triggered=false", () => { + const s = detectSnowball([], 150_000); + assert.equal(s.triggered, false); + assert.equal(s.cumulativeTokens, 0); + assert.equal(s.turns, 0); +}); + +test("detectSnowball: 最初のサンプルの cacheCreation を baseline として差し引く", () => { + // baseline 200k(SessionStart 注入)。最新累積 250k → 成長分 50k。 + const samples = [ + sample(200_000, 200_000, new Date("2026-05-12T00:00:00Z")), + sample(220_000, 0, new Date("2026-05-12T00:05:00Z")), + sample(250_000, 0, new Date("2026-05-12T00:10:00Z")), + ]; + const s = detectSnowball(samples, 150_000, 3); + assert.equal(s.baselineTokens, 200_000); + assert.equal(s.cumulativeTokens, 50_000); + assert.equal(s.triggered, false, "baseline 差し引き後は閾値未満"); +}); + +test("detectSnowball: ターン数が minTurns 未満なら閾値超でも triggered=false", () => { + // baseline 0、成長分 200k だが 2 ターンしか無い + const samples = [ + sample(0, 0, new Date("2026-05-12T00:00:00Z")), + sample(200_000, 0, new Date("2026-05-12T00:05:00Z")), + ]; + const s = detectSnowball(samples, 150_000, 3); + assert.equal(s.cumulativeTokens, 200_000); + assert.equal(s.turns, 2); + assert.equal(s.triggered, false, "minTurns 未満は抑止"); +}); + +test("detectSnowball: baseline 控除後の成長分が閾値を超え、かつ minTurns 以上なら triggered", () => { + const samples = [ + sample(100_000, 100_000, new Date("2026-05-12T00:00:00Z")), + sample(150_000, 0, new Date("2026-05-12T00:05:00Z")), + sample(260_000, 0, new Date("2026-05-12T00:10:00Z")), + sample(360_000, 0, new Date("2026-05-12T00:15:00Z")), + ]; + const s = detectSnowball(samples, 150_000, 3); + assert.equal(s.baselineTokens, 100_000); + assert.equal(s.cumulativeTokens, 260_000); + assert.equal(s.triggered, true); + // 初めて閾値超えた瞬間は累積 260k のサンプル (3 件目) + assert.deepEqual(s.triggeredAt, new Date("2026-05-12T00:10:00Z")); +}); + +test("detectSnowball: heavy SessionStart で初手 300k でも 1 ターンなら誤発火しない", () => { + // ユーザーの実環境を模擬: 初手の cache_creation で 307k 食う + const samples = [sample(307_000, 307_000, new Date("2026-05-12T00:00:00Z"))]; + const s = detectSnowball(samples, 150_000, 3); + assert.equal(s.cumulativeTokens, 0, "成長分はゼロ"); + assert.equal(s.triggered, false); +});