feat(chronicle): E1 temporal recall — chronicle-type boost on temporal queries (#2390)

Life Chronicle Phase A.4 (E1, ambient temporality). Rather than a separate RRF
arm (which needs chunk hydration + risks the fusion path), E1 is a bounded
post-fusion boost: applyChronicleTypeBoost lifts `event`/`diary` results on
temporal queries, wired INSIDE runPostFusionStages' `recency !== 'off'` branch
so it fires ONLY on temporal intent — non-temporal search is bit-for-bit
unchanged (proven by 110 passing search-path tests). Bounded ([1.0,1.25]) +
floor-gated like the other metadata stages; attribution via `chronicle_boost`.
3 unit tests. (Empirical precision/negative measurement lands with the eval.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-06-24 15:24:05 -07:00
co-authored by Claude Opus 4.8
parent dca35a6bd0
commit 41b94ae21e
3 changed files with 77 additions and 0 deletions
+32
View File
@@ -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
+2
View File
@@ -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). */
+43
View File
@@ -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();
});
});