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
5 changes: 5 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ export type CogsyncConfig = {
};
thresholds: {
snowballToken: number;
/** snowball 発火に必要な最小ターン数(heavy-context 起動時の誤発火抑制) */
snowballMinTurns: number;
limitWarnMin: number;
aiWaitBreakMin: number;
/** アクティブ判定: 最新の user/assistant が直近 N 分以内ならアクティブ(top session 揺らぎ抑制) */
Expand Down Expand Up @@ -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,
Expand Down
38 changes: 34 additions & 4 deletions src/infer/snowball.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 参照)。
*/
Expand All @@ -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;
}
Expand All @@ -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,
};
Expand Down
6 changes: 5 additions & 1 deletion src/mcp/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,11 @@ async function buildAdviseInput(ctx: ResourceContext): Promise<AdviseInput> {
// 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)
Expand Down
6 changes: 5 additions & 1 deletion src/watch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
73 changes: 73 additions & 0 deletions tests/snowball.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
Loading