Compare commits

...
Author SHA1 Message Date
Time Attakc 7ff32a8773 Merge branch 'master' into fix/knobs-hash-detail-3515 2026-07-28 15:15:00 -07:00
Garry TanandClaude Opus 5 683b7665f2 fix(search): fold detail into the query-cache key (#3515)
`detail` is result-affecting by design — it gates dedup, chunk-source
filtering, and the compiled_truth boost — but was absent from knobsHash,
the only thing that varies the query-cache key. A `--detail low` write
(compiled-truth-only result set) was served to a default `medium` lookup
for the whole TTL (3600s), silently, looking like a relevance problem.
Same contamination class as [CDX-4], the v=2→3 floor_ratio/col/prov
additions, and the v=9→10 relationalRetrieval fold.

Fix: append `det=` to the knobsHash parts list (append-only, per the
list's own convention) and bump KNOBS_HASH_VERSION 13→15. detail is a
per-call SearchOpts value, not a mode knob, so it threads through
KnobsHashContext the way col=/prov= do; hybridSearchCached passes the
EFFECTIVE level (opts.detail ?? autoDetectDetail(query)) so an
auto-detected `high` query keys like an explicit `high` one. Undefined
falls back to 'medium' (the documented default).

v=14 is claimed by in-flight PR #3514 (#3430); this lands as v=15 per
the established D8 sequencing convention. All five KNOBS_HASH_VERSION
pin sites updated. One-time cache cold-miss on upgrade, refills within
cache.ttl_seconds — same as every prior bump.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-28 13:31:55 -07:00
7 changed files with 99 additions and 10 deletions
+7
View File
@@ -1777,6 +1777,13 @@ export async function hybridSearchCached(
// resolves) into the cache key so a row written under one exclude
// policy can't be served to a lookup under another.
hardExcludes: resolveHardExcludes(opts?.exclude_slug_prefixes, opts?.include_slug_prefixes),
// #3515 — fold the EFFECTIVE detail level into the cache key. detail
// gates dedup, chunk-source filtering, and the compiled_truth boost, so
// a `--detail low` write (compiled-truth-only result set) must never be
// served to a default `medium` lookup. Resolve auto-detect the same way
// bare hybridSearch does (opts.detail ?? autoDetectDetail(query)) so an
// auto-detected `high` query keys like an explicit `high` one.
detail: opts?.detail ?? autoDetectDetail(query),
});
// Cache decision: opts.useCache (explicit) wins over global config; global
+27 -1
View File
@@ -766,7 +766,17 @@ export function attributeKnob<K extends keyof ModeBundle>(
// written between the #3391 stale-fix (which changes which chunks count as
// current) and the operator's migration run. Same one-time global cold-miss
// pattern as the bumps above.
export const KNOBS_HASH_VERSION = 13;
//
// bump 13→15 (#3515): `detail` folds into the key via ctx.detail (det=).
// detail is result-affecting by design — it gates dedup, chunk-source
// filtering, and the compiled_truth boost — but was absent from the key, so
// a `--detail low` write (compiled-truth-only result set) was served to a
// default `medium` lookup for the whole TTL. Same contamination class as
// [CDX-4], floor_ratio (v=3), and relationalRetrieval (v=10). v=14 is
// claimed by in-flight #3514 (compiled_truth boost scope, #3430), so this
// lands as v=15 per the D8 sequencing convention (see the v=4/v=5 note
// above). Same one-time global cold-miss pattern as the bumps above.
export const KNOBS_HASH_VERSION = 15;
/**
* v0.36 (D8 / CDX-2) — second-arg context for the cache key. The
@@ -805,6 +815,17 @@ export interface KnobsHashContext {
* 'none' for legacy callers that don't thread excludes.
*/
hardExcludes?: string[];
/**
* v=15 (#3515): the EFFECTIVE detail level for this call — per-call
* SearchOpts.detail, or the auto-detected level when the caller didn't
* specify (hybridSearchCached threads `opts.detail ?? autoDetectDetail(query)`,
* matching what bare hybridSearch resolves). detail gates dedup,
* chunk-source filtering, and the compiled_truth boost, so a detail=low
* write must never be served to a detail=medium lookup. Lives in ctx (not
* ResolvedSearchKnobs) because it's per-call, not a mode knob — same path
* as col=/prov=. Undefined falls back to 'medium' (the documented default).
*/
detail?: 'low' | 'medium' | 'high';
}
export function knobsHash(
@@ -898,6 +919,11 @@ export function knobsHash(
// across processes. Sorted copy so ['a/','b/'] and ['b/','a/'] hash
// identically; undefined falls back to 'none' for legacy callers.
`hx=${ctx?.hardExcludes ? [...ctx.hardExcludes].sort().join(',') : 'none'}`,
// v=15 addition (#3515, append-only): effective detail level. detail
// gates dedup, chunk-source filtering, and the compiled_truth boost, so
// a low write (compiled-truth-only set) must never be served to a
// medium/high lookup. Undefined falls back to 'medium' (the default).
`det=${ctx?.detail ?? 'medium'}`,
];
const h = createHash('sha256');
h.update(parts.join('|'));
+3 -2
View File
@@ -136,7 +136,7 @@ describe('D2 — knobsHash differs across cross-modal knob values', () => {
return resolveSearchMode({ mode: 'balanced' });
}
test('KNOBS_HASH_VERSION is 13 (cross-modal still appended; 12→13 embedding-provider migration #3390)', () => {
test('KNOBS_HASH_VERSION is 15 (cross-modal still appended; 13→15 detail fold #3515)', () => {
// v0.35 ladder: 1→2 reranker, 2→3 floor_ratio. v0.36 piggybacks on v=3
// with 7 cross-modal knobs + column/provider context. v0.40.4 (salem) +
// v0.39 T21 (master) bump to v=4 for graph_signals + schema-pack fields.
@@ -146,7 +146,8 @@ describe('D2 — knobsHash differs across cross-modal knob values', () => {
// v0.43: 9→10 relational recall arm. #1400: 10→11 query-side input_type
// finally reaches asymmetric providers — pre-fix rows were keyed on
// document-side query vectors. #2825: 11→12 hard-exclude fold (hx=).
expect(KNOBS_HASH_VERSION).toBe(13);
// #3515: 13→15 detail fold (det=); v=14 claimed by in-flight #3514.
expect(KNOBS_HASH_VERSION).toBe(15);
});
test('flipping unified_multimodal changes the hash', () => {
@@ -277,3 +277,38 @@ describe('hard-exclude cache isolation (#2825)', () => {
expect((await cache.lookup(emb, { knobsHash: envExcludeHash })).hit).toBe(true);
});
});
describe('detail cache isolation (#3515)', () => {
// Hashes computed the way hybridSearchCached does: same resolved mode, ctx
// carrying the effective detail level. A row written by a `--detail low`
// call (compiled-truth-only result set) must not be served to a default
// `medium` lookup, and vice versa.
const lowHash = knobsHash(resolveSearchMode({ mode: 'balanced' }), { detail: 'low' });
const mediumHash = knobsHash(resolveSearchMode({ mode: 'balanced' }), { detail: 'medium' });
const unsetHash = knobsHash(resolveSearchMode({ mode: 'balanced' }));
test('detail=low write is NOT served to a default (medium) lookup', async () => {
const cache = new SemanticQueryCache(engine);
const emb = makeEmbedding(8);
// Simulate `query "X" --detail low` populating the cache with the
// narrow compiled-truth-only result set.
await cache.store('what is the deploy process', emb, makeResults('narrow', 2), {
vector_enabled: true, detail_resolved: 'low', expansion_applied: false,
}, { knobsHash: lowHash });
// Default-detail lookup inside the TTL → MISS (falls through to a
// fresh, full search) instead of the narrow set.
expect((await cache.lookup(emb, { knobsHash: mediumHash })).hit).toBe(false);
// The low-detail caller still hits its own row.
const original = await cache.lookup(emb, { knobsHash: lowHash });
expect(original.hit).toBe(true);
expect(original.results?.length).toBe(2);
});
test('undefined detail keys like the documented medium default', () => {
expect(unsetHash).toBe(mediumHash);
expect(unsetHash).not.toBe(lowHash);
});
});
+2 -2
View File
@@ -89,7 +89,7 @@ describe('alias_resolved boost stage', () => {
});
describe('KNOBS_HASH_VERSION', () => {
it('is 13 (12→13 embedding-provider migration invalidates rows written against the prior embedding space, #3390)', () => {
expect(KNOBS_HASH_VERSION).toBe(13);
it('is 15 (13→15 detail fold makes detail-contaminated rows unreachable, #3515; v=14 claimed by in-flight #3514)', () => {
expect(KNOBS_HASH_VERSION).toBe(15);
});
});
+20 -3
View File
@@ -413,7 +413,24 @@ describe('knobsHash determinism + cross-mode separation (CDX-4)', () => {
// #3390/#3391: bumped 12→13 for the embedding-provider migration wave —
// legacy callers hash prov=default before AND after a provider swap, so
// pre-migration cache rows must become unreachable on upgrade.
expect(KNOBS_HASH_VERSION).toBe(13);
// #3515: bumped 13→15 to fold the effective detail level (det=) — a
// detail=low write must not be served to a detail=medium lookup. v=14
// is claimed by in-flight #3514 (#3430 compiled_truth boost scope).
expect(KNOBS_HASH_VERSION).toBe(15);
});
test('#3515: detail set vs unset produces DIFFERENT hashes (cache contamination prevention)', () => {
const knobs = resolveSearchMode({ mode: 'balanced' });
const low = knobsHash(knobs, { detail: 'low' });
const medium = knobsHash(knobs, { detail: 'medium' });
const high = knobsHash(knobs, { detail: 'high' });
const unset = knobsHash(knobs);
expect(low).not.toBe(medium);
expect(medium).not.toBe(high);
expect(low).not.toBe(high);
// Undefined falls back to 'medium' — the documented default — so legacy
// callers that don't thread detail share the default-detail rows.
expect(unset).toBe(medium);
});
test('T1 (codex): floor_ratio set vs unset produces DIFFERENT hashes (cache contamination prevention)', () => {
@@ -578,8 +595,8 @@ describe('v0.40.4 — graph_signals knob', () => {
});
describe('v0.42.3.0 — autocut knobs', () => {
test('KNOBS_HASH_VERSION is 13 (12→13 embedding-migration wave, #3390/#3391)', () => {
expect(KNOBS_HASH_VERSION).toBe(13);
test('KNOBS_HASH_VERSION is 15 (13→15 detail fold #3515; v=14 claimed by in-flight #3514)', () => {
expect(KNOBS_HASH_VERSION).toBe(15);
});
test('bundle defaults: conservative off, balanced/tokenmax on @0.20', () => {
+5 -2
View File
@@ -44,7 +44,7 @@ function baseKnobs(): ResolvedSearchKnobs {
}
describe('KNOBS_HASH_VERSION + version invariants', () => {
test('version is 13 (…; 10→11 asymmetric input_type #1400; 11→12 hard-excludes #2825; 12→13 embedding-provider migration #3390)', () => {
test('version is 15 (…; 11→12 hard-excludes #2825; 12→13 embedding-provider migration #3390; 13→15 detail fold #3515)', () => {
// v0.35.0.0: 1→2 to fold reranker fields. v0.35.6.0: 2→3 to fold
// floor_ratio. v0.36 wave: piggybacks on v=3 with 7 cross-modal knobs
// (D2) PLUS column + provider context (D8/CDX-2 cross-column isolation).
@@ -64,7 +64,10 @@ describe('KNOBS_HASH_VERSION + version invariants', () => {
// pre-fix document-side query vectors must not be served.
// #2825: 11→12 to fold the resolved hard-exclude prefix list (hx=) —
// cached rows leaked GBRAIN_SEARCH_EXCLUDE'd slugs across processes.
expect(KNOBS_HASH_VERSION).toBe(13);
// #3515: 13→15 to fold the effective detail level (det=) — a detail=low
// write must not be served to a detail=medium lookup. v=14 claimed by
// in-flight #3514 (#3430).
expect(KNOBS_HASH_VERSION).toBe(15);
});
test('hash is 16 hex chars regardless of reranker config', () => {