fix(search): classify cache hit/miss in telemetry — hits were invisible, misses unclassified (#2953)

* fix(search): classify cache hit/miss in telemetry — hits were invisible, misses unclassified (#2952)

search stats reported 0 hit / 0 miss forever: recordSearchTelemetry fired
only from bare hybridSearch, whose meta never carries a cache field, and a
cache HIT returned from hybridSearchCached before any record at all — so
hit searches also vanished from count/results/tokens/rank-1.

- HybridSearchOpts: internal _telemetryCacheStatus ('miss' | 'disabled')
  threaded from hybridSearchCached into the inner hybridSearch (same
  pattern as _queryEmbedDeadline), folded into the RECORDED meta only —
  onMeta payloads unchanged, count/sum_tokens/budget_dropped/rank-1
  behavior byte-identical for the miss/disabled paths
- hit path: record once from hybridSearchCached with the already-built
  cachedMeta (cache.status='hit'), post-slice/budget result count, tokens
  from the budget pass, and the same rank-1 rule as the inner paths
- bare hybridSearch direct callers (think/gather, brainstorm, enrich,
  evals, ...) keep recording exactly as before, with no cache field
- test: serial wiring test drives a real store-then-hit roundtrip through
  hybridSearchCached (mocked embedQuery, real PGLite SemanticQueryCache)
  and pins the decision matrix (miss / hit / consult-skipped / bare);
  revert-checked red on pre-fix source at the miss classification

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KFWV7zBZFcmD94Vek6xRDT

* fix(search): harden cache-hit telemetry per review — mode-gated tokens, embed-failure coverage

- hit-path tokens_estimate now gated on the MODE-resolved budget,
  mirroring the inner paths' resolvedMode.tokenBudget > 0 meta condition
  (a tokenmax budget-off brain would otherwise record real tokens on hits
  but 0 on misses, inflating avg-tokens as the hit rate rises)
- test: exact token-delta parity assertion (hit contributes the same
  tokens as the miss that stored the served set) — catches the class of
  hit/miss accounting asymmetry the increase-only check accepted
- test: embed-provider-failure flavor of the disabled path (consult
  degrades via catch, keyword fallback serves, neither counter bumps)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KFWV7zBZFcmD94Vek6xRDT

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Masa
2026-07-20 11:35:02 -07:00
committed by GitHub
co-authored by Claude Fable 5
parent f1031d5a0b
commit 89f226eb38
2 changed files with 281 additions and 1 deletions
+43 -1
View File
@@ -740,6 +740,21 @@ export interface HybridSearchOpts extends SearchOpts {
* a fresh per-call deadline. Not part of the public contract.
*/
_queryEmbedDeadline?: QueryEmbedDeadline;
/**
* INTERNAL — cache-consult outcome threaded from `hybridSearchCached` into
* the inner `hybridSearch` so the ONE telemetry record per search (emitted
* by the inner function) carries the cache classification: 'miss' when the
* semantic cache was consulted and had no row, 'disabled' when the consult
* was skipped (cache off, walk/near-symbol/non-default-column/adaptive
* skip, or the lookup embed failed). Folded into the RECORDED meta only —
* `onMeta` payloads are unchanged. Direct `hybridSearch` callers leave it
* undefined and keep recording with no cache field (they never consulted
* the cache). The cache-HIT record is emitted by `hybridSearchCached`
* itself, since the inner function never runs on a hit. Not part of the
* public contract.
*/
_telemetryCacheStatus?: 'miss' | 'disabled';
}
/**
@@ -943,7 +958,15 @@ export async function hybridSearch(
// swallow — capture telemetry is best-effort
}
try {
recordSearchTelemetry(engine, meta, { results_count: lastResultsCount, rank1_score: lastRank1Score });
// #2952 — fold the cache-consult outcome (threaded by hybridSearchCached)
// into the RECORDED meta only. None of the inner return paths set a
// `cache` field themselves, so this is the sole source of the miss /
// disabled classification; `onMeta` consumers above still receive the
// meta unchanged (the cached wrapper emits its own merged meta to them).
const recordedMeta = opts?._telemetryCacheStatus
? { ...meta, cache: { status: opts._telemetryCacheStatus } }
: meta;
recordSearchTelemetry(engine, recordedMeta, { results_count: lastResultsCount, rank1_score: lastRank1Score });
} catch {
// swallow — telemetry must never break the search hot path.
}
@@ -1753,6 +1776,21 @@ export async function hybridSearchCached(
} catch {
// swallow — telemetry is best-effort
}
// #2952 — a cache hit never reaches the inner hybridSearch (the only
// other telemetry site), so record the search HERE or it vanishes from
// stats entirely (count, results, tokens, rank-1 — not just the hit
// counter). Same rank-1 rule as the inner return paths. Tokens are
// gated on the MODE-resolved budget, mirroring the inner paths' `if
// (resolvedMode.tokenBudget > 0)` meta condition — otherwise a
// tokenmax (budget-off) brain would record real tokens on hits but 0
// on misses, skewing avg-tokens upward as the hit rate rises (codex).
recordSearchTelemetry(engine, cachedMeta, {
results_count: budgeted.length,
...(resolvedForCache.tokenBudget && resolvedForCache.tokenBudget > 0
? { tokens_estimate: budgetMeta.used }
: {}),
rank1_score: budgeted[0] ? (budgeted[0].base_score ?? budgeted[0].score) : undefined,
});
return budgeted;
}
}
@@ -1768,6 +1806,10 @@ export async function hybridSearchCached(
// v0.42.20.0 (Fix 3) — share the query-embed deadline so the inner embed
// doesn't start a fresh 6s budget after the cache-lookup already spent it.
_queryEmbedDeadline: queryEmbedDl,
// #2952 — classify this search's telemetry record (emitted by the inner
// function) with the cache-consult outcome. 'hit' already returned above,
// so only miss/disabled reach this call.
_telemetryCacheStatus: cacheStatus === 'disabled' ? 'disabled' : 'miss',
onMeta: (m) => {
innerMetaBox.current = m;
// Do NOT call userOnMeta here — we'll emit a merged meta below
@@ -0,0 +1,238 @@
/**
* Regression wiring test for #2952 — cache classification reaches telemetry.
*
* Pre-fix, `recordSearchTelemetry` fired only from bare `hybridSearch`, whose
* meta never carries a `cache` field, and a cache HIT returned from
* `hybridSearchCached` before any record at all. Net effect on a live brain:
* `search stats` reported `0 hit / 0 miss` forever while the `query_cache`
* table grew, and hit searches vanished from count/results/tokens/rank-1.
*
* This file drives the REAL pipeline (PGLite brain, real SemanticQueryCache
* store→lookup roundtrip, mocked `embedQuery` for a deterministic vector) and
* pins the decision matrix:
*
* - consulted + no row → recorded once with cache_miss
* - consulted + row → recorded once with cache_hit (plus results/rank-1)
* - consult skipped → recorded once with neither counter
* - bare hybridSearch → recorded once with neither counter (unchanged)
*
* Serial: mock.module + gateway/global-env mutation (isolation guard R2).
*/
import { afterAll, beforeAll, beforeEach, describe, expect, mock, test } from 'bun:test';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import * as realEmbedding from '../src/core/embedding.ts';
/** Deterministic 1536d unit vector — same for every call, so an identical
* query's second consult matches its first write at cosine 1.0. */
function fixedEmbedding(): Float32Array {
const arr = new Float32Array(1536);
for (let i = 0; i < 1536; i++) arr[i] = Math.sin(1 + i * 0.001);
let norm = 0;
for (let i = 0; i < 1536; i++) norm += arr[i] * arr[i];
norm = Math.sqrt(norm);
if (norm > 0) for (let i = 0; i < 1536; i++) arr[i] /= norm;
return arr;
}
// Pluggable behavior so individual tests can simulate an embed-provider
// failure (the 'disabled'-via-catch flavor). null → deterministic vector.
let embedBehavior: (() => Promise<Float32Array>) | null = null;
// Mock the embedding seam BEFORE importing hybrid.ts so both the cache-lookup
// embed and the inner vector-arm embed resolve without a provider call. Spread
// the real module so every other export stays live.
mock.module('../src/core/embedding.ts', () => ({
...realEmbedding,
embed: async () => (embedBehavior ? embedBehavior() : fixedEmbedding()),
embedQuery: async () => (embedBehavior ? embedBehavior() : fixedEmbedding()),
}));
// Import AFTER mocking.
const { hybridSearch, hybridSearchCached, awaitPendingSearchCacheWrites, _resetPendingSearchCacheWritesForTests } =
await import('../src/core/search/hybrid.ts');
const { getTelemetryWriter, _resetTelemetryWriterForTest } = await import('../src/core/search/telemetry.ts');
const { configureGateway, resetGateway } = await import('../src/core/ai/gateway.ts');
const { PGLiteEngine } = await import('../src/core/pglite-engine.ts');
let engine: InstanceType<typeof PGLiteEngine>;
let tmpHome: string;
const savedGbrainHome = process.env.GBRAIN_HOME;
interface Counters {
c: number;
hit: number;
miss: number;
rank1: number;
results: number;
tokens: number;
}
/** Flush the writer and read the summed counters back from the table. */
async function readCounters(): Promise<Counters> {
await getTelemetryWriter().flush();
const rows = await engine.executeRaw<Counters>(
`SELECT COALESCE(SUM(count), 0)::int AS c,
COALESCE(SUM(cache_hit), 0)::int AS hit,
COALESCE(SUM(cache_miss), 0)::int AS miss,
COALESCE(SUM(count_rank1), 0)::int AS rank1,
COALESCE(SUM(sum_results), 0)::int AS results,
COALESCE(SUM(sum_tokens), 0)::int AS tokens
FROM search_telemetry`,
);
return rows[0];
}
beforeAll(async () => {
// Hermetic config home so the developer's real ~/.gbrain/config.json can't
// leak an embedding_model that flips isCacheSafe → 'disabled'.
tmpHome = mkdtempSync(join(tmpdir(), 'gbrain-cache-telemetry-'));
process.env.GBRAIN_HOME = tmpHome;
// Pin the gateway to a 1536d provider BEFORE initSchema so the
// query_cache.embedding column is sized for the mock vectors, and so
// isAvailable('embedding') lets the cache consult proceed. The fake key is
// never used — embedQuery is mocked above. (Pattern:
// test/query-cache-knobs-hash.serial.test.ts.)
resetGateway();
configureGateway({
embedding_model: 'openai:text-embedding-3-large',
embedding_dimensions: 1536,
env: { OPENAI_API_KEY: 'sk-fake' },
});
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
// Keyword-findable fixtures so the inner search returns rows (a non-empty
// result set is what arms the cache writeback). searchKeyword joins
// content_chunks, so pages need explicit chunks — putPage alone leaves the
// chunk table empty (pattern: test/chunk-grain-fts.test.ts).
await engine.putPage('alice-foo', {
type: 'person',
title: 'Alice Foo',
compiled_truth: 'Alice Foo is a builder who ships search telemetry fixtures.',
});
await engine.upsertChunks('alice-foo', [
{ chunk_index: 0, chunk_text: 'Alice Foo is a builder who ships search telemetry fixtures.', chunk_source: 'compiled_truth' },
]);
await engine.putPage('bob-bar', {
type: 'person',
title: 'Bob Bar',
compiled_truth: 'Bob Bar is a builder who reviews cache wiring fixtures.',
});
await engine.upsertChunks('bob-bar', [
{ chunk_index: 0, chunk_text: 'Bob Bar is a builder who reviews cache wiring fixtures.', chunk_source: 'compiled_truth' },
]);
});
afterAll(async () => {
if (savedGbrainHome === undefined) delete process.env.GBRAIN_HOME;
else process.env.GBRAIN_HOME = savedGbrainHome;
try { await engine.disconnect(); } catch { /* ignore */ }
resetGateway();
try { rmSync(tmpHome, { recursive: true, force: true }); } catch { /* ignore */ }
});
beforeEach(async () => {
embedBehavior = null;
_resetTelemetryWriterForTest();
_resetPendingSearchCacheWritesForTests();
await engine.executeRaw('DELETE FROM search_telemetry');
await engine.executeRaw('DELETE FROM query_cache');
});
describe('hybridSearchCached — telemetry carries the cache outcome', () => {
test('miss then hit: one record per search, classified, hit keeps results/rank-1 telemetry', async () => {
// Call 1 — cache consulted, empty → miss.
const first = await hybridSearchCached(engine, 'alice telemetry fixtures', { limit: 5 });
expect(first.length).toBeGreaterThan(0);
await awaitPendingSearchCacheWrites();
// Sanity: the writeback actually landed, so call 2 exercises a REAL hit
// (a broken writeback would otherwise fail the hit assertion ambiguously).
const cacheRows = await engine.executeRaw<{ n: number }>(
'SELECT COUNT(*)::int AS n FROM query_cache',
);
expect(cacheRows[0].n).toBeGreaterThan(0);
const afterMiss = await readCounters();
expect(afterMiss.c).toBe(1);
expect(afterMiss.miss).toBe(1);
expect(afterMiss.hit).toBe(0);
expect(afterMiss.rank1).toBe(1);
expect(afterMiss.results).toBeGreaterThan(0);
// Call 2 — identical query + knobs, deterministic embedding → hit.
let meta: import('../src/core/types.ts').HybridSearchMeta | undefined;
const second = await hybridSearchCached(engine, 'alice telemetry fixtures', {
limit: 5,
onMeta: (m) => { meta = m; },
});
expect(meta?.cache?.status).toBe('hit');
expect(second.length).toBeGreaterThan(0);
const afterHit = await readCounters();
// Pre-fix both sides of this were wrong: hit stayed 0 forever AND the hit
// search was missing from count entirely (c would read 1, not 2).
expect(afterHit.c).toBe(2);
expect(afterHit.miss).toBe(1);
expect(afterHit.hit).toBe(1);
// The hit search contributes results/rank-1/tokens telemetry too.
expect(afterHit.rank1).toBe(2);
expect(afterHit.results).toBeGreaterThan(afterMiss.results);
// Token parity (codex): the hit serves the SAME result set the miss
// stored, so its token contribution must EQUAL the miss's — a hit/miss
// accounting asymmetry (e.g. hits counting tokens the miss convention
// skips) would break this exact-delta check.
expect(afterHit.tokens - afterMiss.tokens).toBe(afterMiss.tokens);
});
test('lookup-embed failure: consult degrades to disabled — recorded once, neither counter', async () => {
embedBehavior = async () => { throw new Error('embed provider down'); };
// The failed consult must not break the search: keyword fallback serves.
const results = await hybridSearchCached(engine, 'bob cache wiring', { limit: 5 });
expect(results.length).toBeGreaterThan(0);
const counters = await readCounters();
expect(counters.c).toBe(1);
expect(counters.hit).toBe(0);
expect(counters.miss).toBe(0);
expect(counters.rank1).toBe(1);
});
test('consult skipped (useCache:false): recorded once, neither counter', async () => {
const results = await hybridSearchCached(engine, 'bob cache wiring', { limit: 5, useCache: false });
expect(results.length).toBeGreaterThan(0);
const counters = await readCounters();
expect(counters.c).toBe(1);
expect(counters.hit).toBe(0);
expect(counters.miss).toBe(0);
// Telemetry otherwise unchanged: the search still counts fully.
expect(counters.rank1).toBe(1);
expect(counters.results).toBeGreaterThan(0);
});
});
describe('bare hybridSearch — direct callers unchanged', () => {
test('records once with no cache classification', async () => {
let meta: import('../src/core/types.ts').HybridSearchMeta | undefined;
const results = await hybridSearch(engine, 'bob cache wiring', {
limit: 5,
onMeta: (m) => { meta = m; },
});
expect(results.length).toBeGreaterThan(0);
// The onMeta contract is untouched: no cache field is injected into the
// caller-visible meta (the fold happens on the recorded copy only).
expect(meta?.cache).toBeUndefined();
const counters = await readCounters();
expect(counters.c).toBe(1);
expect(counters.hit).toBe(0);
expect(counters.miss).toBe(0);
});
});