Files
gbrain/test/search/knobs-hash-reranker.test.ts
T
af7e5379c2 v0.35.6.0 feat(search): floor-ratio gate for metadata boost stages (closes #1091) (#1129)
* v0.35.6.0 feat(search): floor-ratio gate for metadata boost stages

Opt-in score-based gate on the three metadata-axis boost stages (backlink,
salience, recency) inside `runPostFusionStages`. When `SearchOpts.floorRatio`
or `search.floor_ratio` config is set, each stage skips results whose
post-cosine-rescore score is below `floorRatio * topScore`. Default
undefined preserves prior behavior bit-for-bit. Prevents weak-overlap
candidates from accumulating metadata boosts and leapfrogging the
legitimate primary hit on dense-embedder corpora.

Built on the contributor PR from @jayzalowitz (PR #1091, SkyTwin
twin-memory layer). Refactored on top: threshold is computed ONCE at
runPostFusionStages entry instead of per-stage (single-baseline semantic,
order-independent); knobsHash bumped 2->3 so a no-floor cache write can't
be served to a floor-enabled lookup; NaN scores skip the boost instead of
bypassing the gate; SearchOpts/config/MODE_BUNDLES integration replaces
the PR's PostFusionOpts-only surface; no env var (resolveSearchMode is
pure by design).

Three correctness issues codex outside-voice review caught and this
landed with fixed:
- Cache contamination via knobsHash() (same bug class as v0.32.3 CDX-4
  hotfix for the other search-lite knobs)
- NaN scores would have bypassed the gate (NaN < threshold is false in
  JS); realistic on Voyage flexible-dim / zembed-1 Matryoshka dim drift
- Negative top scores would have broken the "single result trivially
  eligible" claim; gate now disables on no-positive-signal inputs

Scope: gates metadata stages only. Exact-match boost
(applyExactMatchBoost) runs independently as a lexical-relevance signal
by design. Cross-source floor stays global (per-source deferred to
v0.36 if federated-read users hit the suppression). Default-on for any
mode bundle deferred until gbrain-side ablation against longmemeval /
whoknows / suspected-contradictions / BrainBench-Real (TODOS.md).

Plan + 9-decision review trail (D1-D9): ~/.claude/plans/swift-sniffing-nygaard.md.
Empirical motivation, failure-mode framing, dense-embedder targeting, and
the 0.85 starting value all from @jayzalowitz's labeled-retrieval
ablation. Integration shape is gbrain-side.

Test surface: 30+ new cases (computeFloorThreshold edge cases including
T1a NaN / T1b negative top, three boost-function gate parity tests
including T6 IRON-RULE applyRecencyBoost regression, runPostFusionStages
single-baseline composition pin, KNOBS_HASH_VERSION bump from 2 to 3,
floor-ratio-changes-hash cache-contamination prevention,
loadOverridesFromConfig coverage for search.floor_ratio config key).
bun run verify clean; full unit suite 6753 pass / 0 fail.

Co-Authored-By: Jay Zalowitz <jayzalowitz@gmail.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs: rewrite v0.35.6.0 CHANGELOG ELI10-lead-first; codify the rule in CLAUDE.md

CHANGELOG entry for v0.35.6.0 was readable only by someone who already
understood gbrain's internals (RRF, knobsHash, MODE_BUNDLES, runPostFusionStages,
Matryoshka, CDX-4). Rewrote it so the first ~150 words explain what
shipped in everyday English, with a concrete worked example, before any
file paths or function names appear. Itemized changes section keeps the
technical precision for engineers who need it.

Then codified the rule in CLAUDE.md so future release entries land the same
way. The "Release-summary template" section now has an iron rule:
"lead ELI10, get precise after." No file paths or internal constants in
the first 150 words; user-visible behavior change first; everyday-language
column headers in any tables. Technical precision is required (the entry
is still the technical record) but lives BELOW the plain-English lead,
never before it.

Smell test: if a reader who has never opened gbrain can walk away from
the first 150 words knowing what shipped and whether they care, the entry
passes.

bun run build:llms regenerated to pick up the CLAUDE.md change (CI guard
test/build-llms.test.ts pins committed bundles against fresh generator
output).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Jay Zalowitz <jayzalowitz@gmail.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 15:41:43 -07:00

154 lines
6.2 KiB
TypeScript

/**
* v0.35.0.0 — knobsHash reranker-field participation tests.
*
* Pins:
* - KNOBS_HASH_VERSION === 3 (bumped 1→2 v0.35.0.0 for reranker; 2→3 v0.35.6.0
* for floor_ratio — codex outside-voice T1 cross-floor cache contamination).
* - All 5 new reranker fields participate in the hash:
* reranker_enabled, reranker_model, reranker_top_n_in,
* reranker_top_n_out, reranker_timeout_ms.
* Each one flipping changes the hash → no two reranker configs share
* a cache row.
* - top_n_out=null vs unset shows up as 'none' in the hash (no NaN).
* - Append-only convention (CDX2-F13): the existing 9 fields hash
* identically under v=2 as they did under v=1 for the same input
* when the reranker section is held constant. Reordering them
* would silently rebuild the hash for every existing row.
* - Mid-deploy invariant (CDX2-F12): the v=1 prefix in the hash input
* differs from the v=2 prefix; a tokenmax v=1 process and a v=2
* process produce distinct row IDs for the same (source_id, query).
*/
import { describe, test, expect } from 'bun:test';
import {
knobsHash,
KNOBS_HASH_VERSION,
resolveSearchMode,
MODE_BUNDLES,
type ResolvedSearchKnobs,
} from '../../src/core/search/mode.ts';
/** Build a baseline resolved knob set with all reranker fields filled. */
function baseKnobs(): ResolvedSearchKnobs {
return {
...MODE_BUNDLES.balanced,
reranker_enabled: false,
reranker_model: 'zeroentropyai:zerank-2',
reranker_top_n_in: 30,
reranker_top_n_out: null,
reranker_timeout_ms: 5000,
resolved_mode: 'balanced',
mode_valid: true,
};
}
describe('KNOBS_HASH_VERSION + version invariants', () => {
test('version is 3 (1→2 v0.35.0.0 reranker; 2→3 v0.35.6.0 floor_ratio)', () => {
expect(KNOBS_HASH_VERSION).toBe(3);
});
test('hash is 16 hex chars regardless of reranker config', () => {
const a = knobsHash(baseKnobs());
const b = knobsHash({ ...baseKnobs(), reranker_enabled: true });
expect(a).toMatch(/^[0-9a-f]{16}$/);
expect(b).toMatch(/^[0-9a-f]{16}$/);
});
});
describe('Each reranker field flips the hash (cache-row separation)', () => {
test('reranker_enabled false vs true → different hash', () => {
const off = knobsHash({ ...baseKnobs(), reranker_enabled: false });
const on = knobsHash({ ...baseKnobs(), reranker_enabled: true });
expect(off).not.toBe(on);
});
test('reranker_model differs → different hash', () => {
const z2 = knobsHash({ ...baseKnobs(), reranker_model: 'zeroentropyai:zerank-2' });
const z1 = knobsHash({ ...baseKnobs(), reranker_model: 'zeroentropyai:zerank-1' });
const z1s = knobsHash({ ...baseKnobs(), reranker_model: 'zeroentropyai:zerank-1-small' });
expect(new Set([z2, z1, z1s]).size).toBe(3);
});
test('reranker_top_n_in differs → different hash', () => {
const a = knobsHash({ ...baseKnobs(), reranker_top_n_in: 30 });
const b = knobsHash({ ...baseKnobs(), reranker_top_n_in: 50 });
expect(a).not.toBe(b);
});
test('reranker_top_n_out null vs 10 → different hash', () => {
const noTrunc = knobsHash({ ...baseKnobs(), reranker_top_n_out: null });
const trunc10 = knobsHash({ ...baseKnobs(), reranker_top_n_out: 10 });
expect(noTrunc).not.toBe(trunc10);
});
test('reranker_timeout_ms differs → different hash (CDX2-F14)', () => {
// CDX2-F14: a timeout change (5s → 100ms) changes search behavior
// (more fail-opens) so stale cache rows must invalidate. Without
// this field in parts[], the rows would silently match.
const t5 = knobsHash({ ...baseKnobs(), reranker_timeout_ms: 5000 });
const t1 = knobsHash({ ...baseKnobs(), reranker_timeout_ms: 1000 });
expect(t5).not.toBe(t1);
});
});
describe('mid-deploy invariant (CDX2-F12)', () => {
test('tokenmax-with-reranker vs tokenmax-without-reranker → distinct hashes', () => {
// tokenmax mode bundle has reranker on. An operator who flips it off
// via `gbrain config set search.reranker.enabled false` produces a
// different cache row, not a shared one.
const tokenmaxOn = knobsHash(resolveSearchMode({ mode: 'tokenmax' }));
const tokenmaxOff = knobsHash(resolveSearchMode({
mode: 'tokenmax',
overrides: { reranker_enabled: false },
}));
expect(tokenmaxOn).not.toBe(tokenmaxOff);
});
test('conservative vs balanced vs tokenmax → 3 distinct hashes', () => {
const c = knobsHash(resolveSearchMode({ mode: 'conservative' }));
const b = knobsHash(resolveSearchMode({ mode: 'balanced' }));
const t = knobsHash(resolveSearchMode({ mode: 'tokenmax' }));
expect(new Set([c, b, t]).size).toBe(3);
});
});
describe('determinism + stability', () => {
test('same input → same hash (re-call)', () => {
const k = baseKnobs();
expect(knobsHash(k)).toBe(knobsHash(k));
});
test('same mode bundle → same hash across resolveSearchMode calls', () => {
const a = knobsHash(resolveSearchMode({ mode: 'balanced' }));
const b = knobsHash(resolveSearchMode({ mode: 'balanced' }));
expect(a).toBe(b);
});
test('top_n_out=null renders as "none" in parts[] (no NaN)', () => {
// CDX2-F15 + F16 + F14 collide here. The parts[] line is
// `rro=${knobs.reranker_top_n_out ?? 'none'}` — null must produce a
// stable string token, never `NaN` or `null`.
const h1 = knobsHash({ ...baseKnobs(), reranker_top_n_out: null });
const h2 = knobsHash({ ...baseKnobs(), reranker_top_n_out: null });
expect(h1).toBe(h2);
expect(h1).toMatch(/^[0-9a-f]{16}$/);
});
});
describe('append-only convention (CDX2-F13)', () => {
test('parts[] order in source: reranker fields appear AFTER the existing 9', async () => {
const src = await Bun.file(
new URL('../../src/core/search/mode.ts', import.meta.url),
).text();
// Locate the parts[] declaration. The existing 9 fields end with
// `lim=${knobs.searchLimit}`. The 5 new fields must appear AFTER
// that line. Reordering would silently rebuild the hash for every
// existing v=2 cache row.
const limIdx = src.indexOf('lim=${knobs.searchLimit}');
const rrIdx = src.indexOf('rr=${knobs.reranker_enabled');
expect(limIdx).toBeGreaterThan(0);
expect(rrIdx).toBeGreaterThan(0);
expect(rrIdx).toBeGreaterThan(limIdx);
});
});