Files
Moltbot/src/memory/temporal-decay.test.ts
Rodrigo Uroz 6b3e0710f4 feat(memory): Add opt-in temporal decay for hybrid search scoring
Exponential decay (half-life configurable, default 30 days) applied
before MMR re-ranking. Dated daily files (memory/YYYY-MM-DD.md) use
filename date; evergreen files (MEMORY.md, topic files) are not
decayed; other sources fall back to file mtime.

Config: memorySearch.query.hybrid.temporalDecay.{enabled, halfLifeDays}
Default: disabled (backwards compatible, opt-in).
2026-02-16 23:59:19 +01:00

174 lines
5.2 KiB
TypeScript

import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { mergeHybridResults } from "./hybrid.js";
import {
applyTemporalDecayToHybridResults,
applyTemporalDecayToScore,
calculateTemporalDecayMultiplier,
} from "./temporal-decay.js";
const DAY_MS = 24 * 60 * 60 * 1000;
const NOW_MS = Date.UTC(2026, 1, 10, 0, 0, 0);
const tempDirs: string[] = [];
async function makeTempDir(): Promise<string> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-temporal-decay-"));
tempDirs.push(dir);
return dir;
}
afterEach(async () => {
await Promise.all(
tempDirs.splice(0).map(async (dir) => {
await fs.rm(dir, { recursive: true, force: true });
}),
);
});
describe("temporal decay", () => {
it("matches exponential decay formula", () => {
const halfLifeDays = 30;
const ageInDays = 10;
const lambda = Math.LN2 / halfLifeDays;
const expectedMultiplier = Math.exp(-lambda * ageInDays);
expect(calculateTemporalDecayMultiplier({ ageInDays, halfLifeDays })).toBeCloseTo(
expectedMultiplier,
);
expect(applyTemporalDecayToScore({ score: 0.8, ageInDays, halfLifeDays })).toBeCloseTo(
0.8 * expectedMultiplier,
);
});
it("is 0.5 exactly at half-life", () => {
expect(calculateTemporalDecayMultiplier({ ageInDays: 30, halfLifeDays: 30 })).toBeCloseTo(0.5);
});
it("does not decay evergreen memory files", async () => {
const dir = await makeTempDir();
const rootMemoryPath = path.join(dir, "MEMORY.md");
const topicPath = path.join(dir, "memory", "projects.md");
await fs.mkdir(path.dirname(topicPath), { recursive: true });
await fs.writeFile(rootMemoryPath, "evergreen");
await fs.writeFile(topicPath, "topic evergreen");
const veryOld = new Date(Date.UTC(2010, 0, 1));
await fs.utimes(rootMemoryPath, veryOld, veryOld);
await fs.utimes(topicPath, veryOld, veryOld);
const decayed = await applyTemporalDecayToHybridResults({
results: [
{ path: "MEMORY.md", score: 1, source: "memory" },
{ path: "memory/projects.md", score: 0.75, source: "memory" },
],
workspaceDir: dir,
temporalDecay: { enabled: true, halfLifeDays: 30 },
nowMs: NOW_MS,
});
expect(decayed[0]?.score).toBeCloseTo(1);
expect(decayed[1]?.score).toBeCloseTo(0.75);
});
it("applies decay in hybrid merging before ranking", async () => {
const merged = await mergeHybridResults({
vectorWeight: 1,
textWeight: 0,
temporalDecay: { enabled: true, halfLifeDays: 30 },
mmr: { enabled: false },
nowMs: NOW_MS,
vector: [
{
id: "old",
path: "memory/2025-01-01.md",
startLine: 1,
endLine: 1,
source: "memory",
snippet: "old but high",
vectorScore: 0.95,
},
{
id: "new",
path: "memory/2026-02-10.md",
startLine: 1,
endLine: 1,
source: "memory",
snippet: "new and relevant",
vectorScore: 0.8,
},
],
keyword: [],
});
expect(merged[0]?.path).toBe("memory/2026-02-10.md");
expect(merged[0]?.score ?? 0).toBeGreaterThan(merged[1]?.score ?? 0);
});
it("handles future dates, zero age, and very old memories", async () => {
const merged = await mergeHybridResults({
vectorWeight: 1,
textWeight: 0,
temporalDecay: { enabled: true, halfLifeDays: 30 },
mmr: { enabled: false },
nowMs: NOW_MS,
vector: [
{
id: "future",
path: "memory/2099-01-01.md",
startLine: 1,
endLine: 1,
source: "memory",
snippet: "future",
vectorScore: 0.9,
},
{
id: "today",
path: "memory/2026-02-10.md",
startLine: 1,
endLine: 1,
source: "memory",
snippet: "today",
vectorScore: 0.8,
},
{
id: "very-old",
path: "memory/2000-01-01.md",
startLine: 1,
endLine: 1,
source: "memory",
snippet: "ancient",
vectorScore: 1,
},
],
keyword: [],
});
const byPath = new Map(merged.map((entry) => [entry.path, entry]));
expect(byPath.get("memory/2099-01-01.md")?.score).toBeCloseTo(0.9);
expect(byPath.get("memory/2026-02-10.md")?.score).toBeCloseTo(0.8);
expect(byPath.get("memory/2000-01-01.md")?.score ?? 1).toBeLessThan(0.001);
});
it("uses file mtime fallback for non-memory sources", async () => {
const dir = await makeTempDir();
const sessionPath = path.join(dir, "sessions", "thread.jsonl");
await fs.mkdir(path.dirname(sessionPath), { recursive: true });
await fs.writeFile(sessionPath, "{}\n");
const oldMtime = new Date(NOW_MS - 30 * DAY_MS);
await fs.utimes(sessionPath, oldMtime, oldMtime);
const decayed = await applyTemporalDecayToHybridResults({
results: [{ path: "sessions/thread.jsonl", score: 1, source: "sessions" }],
workspaceDir: dir,
temporalDecay: { enabled: true, halfLifeDays: 30 },
nowMs: NOW_MS,
});
expect(decayed[0]?.score).toBeCloseTo(0.5, 2);
});
});