fix(search): fold resolved source-boost map into query-cache knobs hash (takeover of #3213)

Boosts reorder results at query-build time only, so a ranking-policy
change (GBRAIN_SOURCE_BOOST tune or new defaults) kept serving rows
ranked under the old policy for up to cache.ttl_seconds. Folds
resolveBoostMap() into the cache key (KNOBS_HASH_VERSION 12 -> 13,
canonicalized sb= part, same pattern as #2825's hx=).

Repair over #3213: the PR left two KNOBS_HASH_VERSION pins at 12
(test/cross-modal-phase1.test.ts, test/search-alias-resolved-boost.test.ts),
which would have failed CI. Both updated to 13 with the bump rationale.

Co-authored-by: SVZTeam <SVZTeam@users.noreply.github.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-07-23 11:00:19 -07:00
co-authored by SVZTeam Claude Fable 5
parent e79b8d5780
commit 9fca071458
6 changed files with 87 additions and 12 deletions
+6 -1
View File
@@ -15,7 +15,7 @@ import type { SearchResult, SearchOpts, HybridSearchMeta } from '../types.ts';
import { embed, embedQuery } from '../embedding.ts';
import { registerBackgroundWorkDrainer } from '../background-work.ts';
import { resolveEmbeddingColumn, isCacheSafe } from './embedding-column.ts';
import { resolveHardExcludes } from './source-boost.ts';
import { resolveHardExcludes, resolveBoostMap } from './source-boost.ts';
import {
resolveAdaptiveReturn,
applyAdaptiveReturn,
@@ -1702,6 +1702,11 @@ 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),
// v=13 — fold the resolved source-boost map (defaults merged with
// GBRAIN_SOURCE_BOOST) into the cache key. Boosts reorder results at
// query-build time only, so without this a ranking-policy change kept
// serving rows ranked under the old policy for up to cache.ttl_seconds.
sourceBoostMap: resolveBoostMap(),
});
// Cache decision: opts.useCache (explicit) wins over global config; global
+33 -1
View File
@@ -756,7 +756,16 @@ export function attributeKnob<K extends keyof ModeBundle>(
// slugs written by a process without it, and vice versa. Same one-time
// global cold-miss pattern as the bumps above; refills within
// cache.ttl_seconds (3600s default).
export const KNOBS_HASH_VERSION = 12;
//
// bump 12→13: the resolved source-boost map (DEFAULT_SOURCE_BOOSTS merged
// with GBRAIN_SOURCE_BOOST) folds into the key via ctx.sourceBoostMap. Same
// bug class as #2825's hard-excludes: boosts only applied at DB-query build
// time (cache miss), so after a ranking-policy change (env tune or upgrade
// that ships new defaults) lookups kept being served rows ranked under the
// OLD policy for up to cache.ttl_seconds — making boost tuning appear
// nondeterministic. Same one-time global cold-miss pattern; refills within
// cache.ttl_seconds (3600s default).
export const KNOBS_HASH_VERSION = 13;
/**
* v0.36 (D8 / CDX-2) — second-arg context for the cache key. The
@@ -795,6 +804,17 @@ export interface KnobsHashContext {
* 'none' for legacy callers that don't thread excludes.
*/
hardExcludes?: string[];
/**
* v=13: the RESOLVED effective source-boost map — the same value
* resolveBoostMap() produces at query-build time (DEFAULT_SOURCE_BOOSTS
* merged with GBRAIN_SOURCE_BOOST). Ranking priors change result ORDER,
* not membership, so they are invisible to the hard-exclude fold above —
* yet a cache row ranked under an old boost policy is exactly as stale as
* one filtered under an old exclude policy. Canonicalized (entries sorted
* by prefix) so object-key insertion order is irrelevant. Undefined falls
* back to the literal 'default' for legacy callers.
*/
sourceBoostMap?: Record<string, number>;
}
export function knobsHash(
@@ -888,6 +908,18 @@ 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=13 addition (append-only): resolved source-boost map. Boost changes
// reorder results, so a row ranked under one policy must not be served
// to a lookup under another. Entries sorted by prefix so map insertion
// order is irrelevant; undefined falls back to 'default'.
`sb=${
ctx?.sourceBoostMap
? Object.entries(ctx.sourceBoostMap)
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
.map(([prefix, factor]) => `${prefix}:${factor}`)
.join(',')
: 'default'
}`,
];
const h = createHash('sha256');
h.update(parts.join('|'));
+4 -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 12 (cross-modal still appended; 11→12 hard-exclude fold #2825)', () => {
test('KNOBS_HASH_VERSION is 13 (cross-modal still appended; 12→13 source-boost map fold)', () => {
// 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,9 @@ 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(12);
// 12→13: source-boost map fold (sb=) — a ranking-policy change must not
// be served rows ranked under the previous policy.
expect(KNOBS_HASH_VERSION).toBe(13);
});
test('flipping unified_multimodal changes the hash', () => {
+2 -2
View File
@@ -89,7 +89,7 @@ describe('alias_resolved boost stage', () => {
});
describe('KNOBS_HASH_VERSION', () => {
it('is 12 (11→12 hard-exclude fold invalidates rows written under a different exclude policy, #2825)', () => {
expect(KNOBS_HASH_VERSION).toBe(12);
it('is 13 (12→13 source-boost map fold invalidates rows ranked under a different boost policy)', () => {
expect(KNOBS_HASH_VERSION).toBe(13);
});
});
+6 -3
View File
@@ -410,7 +410,10 @@ describe('knobsHash determinism + cross-mode separation (CDX-4)', () => {
// #2825: bumped 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(12);
// bumped 12→13 to fold the resolved source-boost map (sb=) — a
// ranking-policy change (GBRAIN_SOURCE_BOOST tune or new defaults)
// must not be served rows ranked under the previous policy.
expect(KNOBS_HASH_VERSION).toBe(13);
});
test('T1 (codex): floor_ratio set vs unset produces DIFFERENT hashes (cache contamination prevention)', () => {
@@ -575,8 +578,8 @@ describe('v0.40.4 — graph_signals knob', () => {
});
describe('v0.42.3.0 — autocut knobs', () => {
test('KNOBS_HASH_VERSION is 12 (11→12 hard-exclude fold, #2825)', () => {
expect(KNOBS_HASH_VERSION).toBe(12);
test('KNOBS_HASH_VERSION is 13 (12→13 source-boost map fold)', () => {
expect(KNOBS_HASH_VERSION).toBe(13);
});
test('bundle defaults: conservative off, balanced/tokenmax on @0.20', () => {
+36 -3
View File
@@ -27,7 +27,7 @@ import {
MODE_BUNDLES,
type ResolvedSearchKnobs,
} from '../../src/core/search/mode.ts';
import { resolveHardExcludes } from '../../src/core/search/source-boost.ts';
import { resolveHardExcludes, resolveBoostMap } from '../../src/core/search/source-boost.ts';
/** Build a baseline resolved knob set with all reranker fields filled. */
function baseKnobs(): ResolvedSearchKnobs {
@@ -44,7 +44,7 @@ function baseKnobs(): ResolvedSearchKnobs {
}
describe('KNOBS_HASH_VERSION + version invariants', () => {
test('version is 12 (…; 9→10 relational recall; 10→11 asymmetric input_type #1400; 11→12 hard-excludes #2825)', () => {
test('version is 13 (…; 10→11 asymmetric input_type #1400; 11→12 hard-excludes #2825; 12→13 source-boost map)', () => {
// 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,9 @@ 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(12);
// 12→13 to fold the resolved source-boost map (sb=) — a ranking-policy
// change must not be served rows ranked under the previous policy.
expect(KNOBS_HASH_VERSION).toBe(13);
});
test('hash is 16 hex chars regardless of reranker config', () => {
@@ -244,3 +246,34 @@ describe('v=12 hard-exclude participation (#2825)', () => {
);
});
});
describe('v=13 source-boost map participation', () => {
test('different boost maps → different hashes', () => {
const k = baseKnobs();
const defaults = knobsHash(k, { sourceBoostMap: resolveBoostMap(undefined) });
const tuned = knobsHash(k, { sourceBoostMap: resolveBoostMap('originals/:1.8,daily/:0.4') });
expect(defaults).not.toBe(tuned);
});
test('same map with different key insertion order → SAME hash (canonicalization)', () => {
const k = baseKnobs();
const a = knobsHash(k, { sourceBoostMap: { 'a/': 1.2, 'b/': 0.7 } });
const b = knobsHash(k, { sourceBoostMap: { 'b/': 0.7, 'a/': 1.2 } });
expect(a).toBe(b);
});
test('factor change on a single prefix changes the hash', () => {
const k = baseKnobs();
const a = knobsHash(k, { sourceBoostMap: { 'skills/': 1.2 } });
const b = knobsHash(k, { sourceBoostMap: { 'skills/': 1.3 } });
expect(a).not.toBe(b);
});
test('undefined sourceBoostMap is stable and distinct from an explicit map (legacy-caller fallback)', () => {
const k = baseKnobs();
expect(knobsHash(k)).toBe(knobsHash(k));
expect(knobsHash(k)).not.toBe(
knobsHash(k, { sourceBoostMap: resolveBoostMap(undefined) }),
);
});
});