diff --git a/src/core/search/hybrid.ts b/src/core/search/hybrid.ts index 27da6b8c1..7b451ea63 100644 --- a/src/core/search/hybrid.ts +++ b/src/core/search/hybrid.ts @@ -334,6 +334,32 @@ export function applyTitleBoost( /** Default title-phrase boost multiplier (mode-overridable via `title_boost`). */ export const DEFAULT_TITLE_BOOST = 1.25; +/** + * v0.42.x — Life Chronicle (#2390) E1 temporal recall arm. On temporal queries + * (the caller gates this on recency !== 'off'), give chronicle `event`/`diary` + * pages a bounded boost so the timeline surfaces for "what happened…" / "when + * did…" queries — ambient temporality without a separate recall arm. Bounded + * ([1.0, 1.25]) + floor-gated like the other metadata stages, so it can't + * leapfrog a strong primary hit. Mutate-in-place; caller re-sorts. Pure no-op + * for non-chronicle results. NOT called on non-temporal queries (recency='off'), + * so ordinary search is bit-for-bit unchanged. + */ +export function applyChronicleTypeBoost( + results: SearchResult[], + strength: 'on' | 'strong', + floorThreshold?: number, +): void { + const factor = strength === 'strong' ? 1.25 : 1.15; + for (const r of results) { + if (!Number.isFinite(r.score)) continue; + if (floorThreshold !== undefined && r.score < floorThreshold) continue; + if (r.type === 'event' || r.type === 'diary') { + r.score *= factor; + r.chronicle_boost = factor; + } + } +} + /** * v0.29.1 — runPostFusionStages: wrap backlink + salience + recency in a * single stage that fires from EVERY hybridSearch return path (codex @@ -472,6 +498,12 @@ export async function runPostFusionStages( } catch { // Non-fatal. } + + // v0.42.x — Life Chronicle (#2390) E1: chronicle event/diary type boost. + // Gated INSIDE the recency!=off branch so it fires ONLY on temporal queries; + // non-temporal search never reaches here → bit-for-bit unchanged. Shares the + // floor threshold so it can't leapfrog a strong primary hit. + applyChronicleTypeBoost(results, opts.recency, floorThreshold); } // T2 — title-phrase boost. Runs after the metadata stages, before graph diff --git a/src/core/types.ts b/src/core/types.ts index 2a423e005..f94c049e4 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -761,6 +761,8 @@ export interface SearchResult { salience_boost?: number; /** Multiplier applied by applyRecencyBoost. */ recency_boost?: number; + /** v0.42.x (#2390) — multiplier applied by applyChronicleTypeBoost (event/diary on temporal queries). */ + chronicle_boost?: number; /** Multiplier applied by applyExactMatchBoost. */ exact_match_boost?: number; /** Multiplier applied by applyGraphSignals (adjacency hit). */ diff --git a/test/chronicle-search-boost.test.ts b/test/chronicle-search-boost.test.ts new file mode 100644 index 000000000..5b5a75af8 --- /dev/null +++ b/test/chronicle-search-boost.test.ts @@ -0,0 +1,43 @@ +/** + * v0.42.x — Life Chronicle (#2390) E1 temporal recall boost (Phase A.4). + * Pure-function test of applyChronicleTypeBoost: boosts event/diary on temporal + * queries, leaves everything else untouched (the no-regression guarantee — the + * function is only CALLED when recency !== 'off', and even then only moves + * chronicle types). + */ +import { describe, test, expect } from 'bun:test'; +import { applyChronicleTypeBoost } from '../src/core/search/hybrid.ts'; +import type { SearchResult } from '../src/core/types.ts'; + +function r(slug: string, type: string, score = 1.0): SearchResult { + return { + slug, page_id: 1, title: slug, type, chunk_text: '', chunk_source: 'compiled_truth', + chunk_id: 1, chunk_index: 0, score, stale: false, + } as SearchResult; +} + +describe('applyChronicleTypeBoost', () => { + test('boosts event + diary, leaves other types untouched', () => { + const rows = [r('life/events/a', 'event'), r('life/diary/b', 'diary'), r('wiki/note', 'note'), r('people/x', 'person')]; + applyChronicleTypeBoost(rows, 'on'); + expect(rows[0].score).toBeCloseTo(1.15); + expect(rows[0].chronicle_boost).toBeCloseTo(1.15); + expect(rows[1].score).toBeCloseTo(1.15); + expect(rows[2].score).toBe(1.0); // note unchanged + expect(rows[2].chronicle_boost).toBeUndefined(); + expect(rows[3].score).toBe(1.0); // person unchanged + }); + + test('strong uses a larger factor', () => { + const rows = [r('life/events/a', 'event')]; + applyChronicleTypeBoost(rows, 'strong'); + expect(rows[0].score).toBeCloseTo(1.25); + }); + + test('floor gate skips low-score results', () => { + const rows = [r('life/events/a', 'event', 0.1)]; + applyChronicleTypeBoost(rows, 'on', 0.5); // 0.1 < floor 0.5 → skipped + expect(rows[0].score).toBe(0.1); + expect(rows[0].chronicle_boost).toBeUndefined(); + }); +});