mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user