mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-31 04:07:52 +00:00
* 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>
154 lines
6.2 KiB
TypeScript
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);
|
|
});
|
|
});
|