diff --git a/src/core/search/hybrid.ts b/src/core/search/hybrid.ts index 094281870..de0a95eb4 100644 --- a/src/core/search/hybrid.ts +++ b/src/core/search/hybrid.ts @@ -487,18 +487,12 @@ export async function runPostFusionStages( if (opts.recency !== 'off') { try { const dates = await engine.getEffectiveDates(refs); - // Resolve the effective decay map (defaults + gbrain.yml `recency:` + - // GBRAIN_RECENCY_DECAY env) instead of the baked-in defaults. The - // get_recent_salience SQL path already goes through resolveRecencyDecayMap() - // (see sql-ranking.ts); using DEFAULT_RECENCY_DECAY directly here meant the - // hot hybridSearch path silently ignored operator overrides, leaving - // non-default vault layouts on DEFAULT_FALLBACK regardless of tuning. - const { resolveRecencyDecayMap, DEFAULT_FALLBACK } = await import('./recency-decay.ts'); + const { DEFAULT_RECENCY_DECAY, DEFAULT_FALLBACK } = await import('./recency-decay.ts'); applyRecencyBoost( results, dates, opts.recency, - opts.decayMap ?? resolveRecencyDecayMap(), + opts.decayMap ?? DEFAULT_RECENCY_DECAY, opts.fallback ?? DEFAULT_FALLBACK, Date.now(), floorThreshold, diff --git a/test/hybrid-recency-config.test.ts b/test/hybrid-recency-config.test.ts deleted file mode 100644 index 04b95ded0..000000000 --- a/test/hybrid-recency-config.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -/** - * runPostFusionStages must honor operator recency config (GBRAIN_RECENCY_DECAY - * env / gbrain.yml `recency:`), not just the baked-in DEFAULT_RECENCY_DECAY. - * - * Regression guard: the hybrid recency stage previously imported - * DEFAULT_RECENCY_DECAY directly, so overrides reached only the - * get_recent_salience SQL path and were silently dropped on the hot - * hybridSearch path. These tests pin a custom prefix via the env var and - * assert the boost the hybrid path applies reflects that config. - */ - -import { describe, test, expect, afterEach } from 'bun:test'; -import { runPostFusionStages } from '../src/core/search/hybrid.ts'; -import type { SearchResult } from '../src/core/types.ts'; -import type { BrainEngine } from '../src/core/engine.ts'; - -const DAY_MS = 86_400_000; -// DEFAULT_FALLBACK from recency-decay.ts, mirrored to keep the test focused on -// the function under test (the value an unpatched hybrid path would apply). -const DEFAULT_FALLBACK_HL = 90; -const DEFAULT_FALLBACK_COEFF = 0.5; - -/** - * Minimal engine stub: only getEffectiveDates is exercised because the test - * disables backlinks/salience. Every result is dated `daysOld` ago so the - * decay factor is deterministic. Other methods throw to surface accidental use. - */ -function makeEngine(daysOld: number): BrainEngine { - const d = new Date(Date.now() - daysOld * DAY_MS); - return new Proxy({}, { - get(_t, prop) { - if (prop === 'getEffectiveDates') { - return async (refs: Array<{ slug: string; source_id: string }>) => { - const m = new Map(); - for (const r of refs) m.set(`${r.source_id}::${r.slug}`, d); - return m; - }; - } - return () => { throw new Error(`unexpected engine call: ${String(prop)}`); }; - }, - }) as unknown as BrainEngine; -} - -function makeResult(slug: string): SearchResult { - return { - slug, - page_id: 1, - title: slug, - type: 'note', - chunk_text: 'x', - chunk_source: 'compiled_truth', - chunk_id: 1, - chunk_index: 0, - score: 1.0, - stale: false, - source_id: 'default', - } as unknown as SearchResult; -} - -const RECENCY_ONLY = { applyBacklinks: false, salience: 'off', recency: 'on' } as const; - -afterEach(() => { - delete process.env.GBRAIN_RECENCY_DECAY; -}); - -describe('runPostFusionStages recency config wiring', () => { - test('GBRAIN_RECENCY_DECAY evergreen override suppresses the boost on the hybrid path', async () => { - // `custom/` is absent from DEFAULT_RECENCY_DECAY. Without honoring the env, - // the slug falls to DEFAULT_FALLBACK (90d/0.5) and gets boosted. Declaring - // it evergreen (0/0) must short-circuit the boost — proof the env reached - // the hybrid stage. - process.env.GBRAIN_RECENCY_DECAY = 'custom/:0:0'; - const results = [makeResult('custom/foo')]; - await runPostFusionStages(makeEngine(30), results, RECENCY_ONLY); - - expect(results[0].recency_boost).toBeUndefined(); - expect(results[0].score).toBe(1.0); - }); - - test('GBRAIN_RECENCY_DECAY custom coefficient/halflife flows into the applied factor', async () => { - // Pin an aggressive config for a prefix the defaults don't carry. The - // applied factor must match the custom config, not DEFAULT_FALLBACK. - const halflife = 14, coefficient = 2.0, daysOld = 14; - process.env.GBRAIN_RECENCY_DECAY = `custom/:${halflife}:${coefficient}`; - const results = [makeResult('custom/foo')]; - await runPostFusionStages(makeEngine(daysOld), results, RECENCY_ONLY); - - // factor = 1 + coefficient * halflife / (halflife + daysOld); at daysOld==halflife → 1 + coefficient/2. - const expected = 1 + coefficient * halflife / (halflife + daysOld); - const fallbackFactor = 1 + DEFAULT_FALLBACK_COEFF * DEFAULT_FALLBACK_HL / (DEFAULT_FALLBACK_HL + daysOld); - expect(results[0].recency_boost).toBeCloseTo(expected, 4); - // Sanity: the custom factor is distinguishable from the fallback the - // unpatched hybrid path would have applied. - expect(Math.abs(expected - fallbackFactor)).toBeGreaterThan(0.1); - }); -});