diff --git a/src/core/search/hybrid.ts b/src/core/search/hybrid.ts index de0a95eb4..094281870 100644 --- a/src/core/search/hybrid.ts +++ b/src/core/search/hybrid.ts @@ -487,12 +487,18 @@ export async function runPostFusionStages( if (opts.recency !== 'off') { try { const dates = await engine.getEffectiveDates(refs); - const { DEFAULT_RECENCY_DECAY, DEFAULT_FALLBACK } = await import('./recency-decay.ts'); + // 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'); applyRecencyBoost( results, dates, opts.recency, - opts.decayMap ?? DEFAULT_RECENCY_DECAY, + opts.decayMap ?? resolveRecencyDecayMap(), opts.fallback ?? DEFAULT_FALLBACK, Date.now(), floorThreshold, diff --git a/test/hybrid-recency-config.test.ts b/test/hybrid-recency-config.test.ts new file mode 100644 index 000000000..04b95ded0 --- /dev/null +++ b/test/hybrid-recency-config.test.ts @@ -0,0 +1,96 @@ +/** + * 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); + }); +});