Browse Source

fix(context-hub): 修正可缓存命中率含任务级命中

命中率分子扣除 taskScopedHits,避免同任务重试后比率被抬高甚至超过 100%。

Co-authored-by: Cursor <cursoragent@cursor.com>
darknessomi 10 hours ago
parent
commit
40b6b2a2db

+ 2 - 5
src/components/common/context-hub-stats-summary.tsx

@@ -1,3 +1,4 @@
+import { cacheableHitRate } from "@/lib/context-hub/cacheable-hit-rate"
 import type { ContextHubStats } from "@/lib/context-hub/types"
 
 function formatTokens(tokens: number): string {
@@ -24,11 +25,7 @@ export function ContextHubStatsSummary({ stats, className }: ContextHubStatsSumm
   }
 
   // 任务级(查询依赖)数据源无法跨消息复用,不计入可缓存命中率;旧快照缺失按 0 处理。
-  const taskScopedLoaded = stats.taskScopedLoaded ?? 0
-  const cacheableTotal = total - taskScopedLoaded
-  const hitRate = cacheableTotal > 0
-    ? Math.round((stats.cacheHits / cacheableTotal) * 100)
-    : 0
+  const hitRate = cacheableHitRate(stats)
   // 实际注入上下文的估算 token(真实尺度);相比全量候选没发送的部分才叫「节省」。
   const composedTokens = stats.composedTokens ?? 0
   const savedTokens = stats.estimatedSavedTokens ?? 0

+ 48 - 0
src/lib/context-hub/cacheable-hit-rate.spec.ts

@@ -0,0 +1,48 @@
+import { describe, expect, it } from "vitest"
+import { cacheableHitRate } from "./cacheable-hit-rate"
+
+function counters(overrides: Partial<Parameters<typeof cacheableHitRate>[0]> = {}) {
+  return {
+    cacheHits: 0,
+    reloaded: 0,
+    empty: 0,
+    fallbackUsed: 0,
+    readFailed: 0,
+    writeFailed: 0,
+    ...overrides,
+  }
+}
+
+describe("cacheableHitRate", () => {
+  it("excludes task-scoped hits from the cacheable rate", () => {
+    // 可缓存 2 hit + 2 reload,任务级 2 hit → 真实可缓存命中率 50%,旧公式会算出 100%。
+    expect(cacheableHitRate(counters({
+      cacheHits: 4,
+      reloaded: 2,
+      taskScopedLoaded: 2,
+      taskScopedHits: 2,
+    }))).toBe(50)
+  })
+
+  it("does not exceed 100% when only task-scoped sources hit", () => {
+    // 任务级全命中、可缓存几乎全 miss:旧公式 cacheHits / cacheableTotal = 3/1 = 300%。
+    expect(cacheableHitRate(counters({
+      cacheHits: 3,
+      reloaded: 1,
+      taskScopedLoaded: 3,
+      taskScopedHits: 3,
+    }))).toBe(0)
+  })
+
+  it("treats missing task-scoped fields as 0 for old snapshots", () => {
+    expect(cacheableHitRate(counters({ cacheHits: 2, reloaded: 2 }))).toBe(50)
+  })
+
+  it("returns 0 when there is no cacheable total", () => {
+    expect(cacheableHitRate(counters({
+      cacheHits: 2,
+      taskScopedLoaded: 2,
+      taskScopedHits: 2,
+    }))).toBe(0)
+  })
+})

+ 19 - 0
src/lib/context-hub/cacheable-hit-rate.ts

@@ -0,0 +1,19 @@
+import type { ContextHubStats } from "./types"
+
+export function cacheableHitRate(stats: Pick<
+  ContextHubStats,
+  "cacheHits" | "reloaded" | "empty" | "fallbackUsed" | "readFailed" | "writeFailed" | "taskScopedLoaded" | "taskScopedHits"
+>): number {
+  const total =
+    stats.cacheHits
+    + stats.reloaded
+    + stats.empty
+    + stats.fallbackUsed
+    + stats.readFailed
+    + stats.writeFailed
+  const cacheableHits = stats.cacheHits - (stats.taskScopedHits ?? 0)
+  const cacheableTotal = total - (stats.taskScopedLoaded ?? 0)
+  return cacheableTotal > 0
+    ? Math.round((cacheableHits / cacheableTotal) * 100)
+    : 0
+}

+ 1 - 0
src/lib/context-hub/context-hub.ts

@@ -317,6 +317,7 @@ export class ContextHubController implements ContextHub {
         writeFailed: cacheStats.writeFailed,
         cacheHitTokens: cacheStats.cacheHitTokens,
         taskScopedLoaded: cacheStats.taskScopedLoaded,
+        taskScopedHits: cacheStats.taskScopedHits,
         stablePrefixStatus,
       },
       cacheItems: withRelativeDependencyPaths(this.projectPath, cacheItems),

+ 33 - 0
src/lib/context-hub/data-source-cache.spec.ts

@@ -255,6 +255,39 @@ describe("DataSourceCacheAdapter", () => {
     expect(forced).toEqual(refreshed)
   })
 
+  it("counts task-scoped hits separately from cacheable hits", async () => {
+    const harness = createHarness()
+    const source: DataSource<string> = { name: "searchResults", priority: 1, load: async () => "" }
+    const directLoad = vi.fn(async () => "检索结果")
+
+    await harness.adapter.load(source, context, directLoad)
+    await harness.adapter.load(source, context, directLoad)
+
+    expect(directLoad).toHaveBeenCalledOnce()
+    expect(harness.adapter.getStats()).toMatchObject({
+      cacheHits: 1,
+      reloaded: 1,
+      taskScopedLoaded: 2,
+      taskScopedHits: 1,
+    })
+  })
+
+  it("does not count chapter-scoped hits as task-scoped", async () => {
+    const harness = createHarness()
+    const source: DataSource<string> = { name: "outline", priority: 1, load: async () => "" }
+    const directLoad = vi.fn(async () => "大纲")
+
+    await harness.adapter.load(source, context, directLoad)
+    await harness.adapter.load(source, context, directLoad)
+
+    expect(harness.adapter.getStats()).toMatchObject({
+      cacheHits: 1,
+      reloaded: 1,
+      taskScopedLoaded: 0,
+      taskScopedHits: 0,
+    })
+  })
+
   it("invalidates search results when a snapshot or community-summary file is added", async () => {
     const harness = createHarness()
     const source: DataSource<string> = { name: "searchResults", priority: 1, load: async () => "" }

+ 4 - 0
src/lib/context-hub/data-source-cache.ts

@@ -45,6 +45,8 @@ interface DataSourceCacheStats {
   cacheHitTokens: number
   /** 本轮加载的任务级(查询依赖)数据源数量;这类源无法跨消息复用,不计入可缓存命中率。 */
   taskScopedLoaded: number
+  /** 本轮任务级数据源的缓存命中数;从可缓存命中率分子中扣除。 */
+  taskScopedHits: number
 }
 
 const STATIC_SOURCES = new Set([
@@ -153,6 +155,7 @@ export class DataSourceCacheAdapter implements DataSourceLoadAdapter {
     writeFailed: 0,
     cacheHitTokens: 0,
     taskScopedLoaded: 0,
+    taskScopedHits: 0,
   }
   private readonly traceItems: ContextCacheItemTrace[] = []
 
@@ -262,6 +265,7 @@ export class DataSourceCacheAdapter implements DataSourceLoadAdapter {
         const cached = await this.options.storage.readArtifact<T>(key)
         if (cached && dependencyStampsMatch(cached.dependencyStamp, dependencyStamp)) {
           this.stats.cacheHits += 1
+          if (TASK_SCOPED_SOURCES.has(sourceName)) this.stats.taskScopedHits += 1
           this.stats.cacheHitTokens += valueToTokens(cached.value)
           this.upsertTrace(makeTrace("cache_hit"))
           return cached.value

+ 2 - 0
src/lib/context-hub/types.ts

@@ -124,6 +124,8 @@ export interface ContextHubStats {
   cacheHitTokens?: number
   /** 本轮加载的任务级(查询依赖)数据源数量;不计入可缓存命中率。旧快照缺失时按 0 处理。 */
   taskScopedLoaded?: number
+  /** 本轮任务级数据源的缓存命中数;从可缓存命中率分子中扣除。旧快照缺失时按 0 处理。 */
+  taskScopedHits?: number
   stablePrefixStatus?: StablePrefixStatus
   /** Estimated tokens (local heuristic). */
   stableTokens: number