fix(search): honor recency decay config on the hybrid path (#2386)

The hybrid recency stage in runPostFusionStages imported
DEFAULT_RECENCY_DECAY directly, so operator overrides via the
GBRAIN_RECENCY_DECAY env var and the gbrain.yml `recency:` section were
honored only on the get_recent_salience SQL path and silently ignored on
the hot hybridSearch path. Non-default vault layouts therefore stayed on
the baked-in defaults / DEFAULT_FALLBACK (90d / 0.5) regardless of
tuning.

Call resolveRecencyDecayMap() (already used by the SQL path) so the
configured decay map reaches the boost stage. Behavior is unchanged when
no override is set — resolveRecencyDecayMap() returns DEFAULT_RECENCY_DECAY.

Adds test/hybrid-recency-config.test.ts asserting the env override
reaches the applied recency factor (fails against the prior wiring).
This commit is contained in:
Richard Baker
2026-07-23 05:03:43 -07:00
committed by GitHub
parent 23df0227bd
commit 0367c800a4
2 changed files with 104 additions and 2 deletions
+8 -2
View File
@@ -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,
+96
View File
@@ -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<string, Date>();
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);
});
});