Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions src/access-tracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -198,6 +198,38 @@ export function computeEffectiveHalfLife(
return Math.min(result, cap);
}

// ============================================================================
// Hotness Score
// ============================================================================

/**
* Compute a hotness score (0-1) for a memory entry.
*
* Combines access frequency (sigmoid of log1p) with recency of last access
* (exponential decay). Inspired by OpenViking memory_lifecycle.py.
*
* @param accessCount Number of times this memory was recalled
* @param lastAccessMs Timestamp (ms) of last access
* @param decayRate Decay rate (default 0.1 ≈ 7-day half-life)
* @returns 0-1 score. High = frequently + recently accessed.
*/
export function computeHotnessScore(
accessCount: number,
lastAccessMs: number,
decayRate = 0.1,
): number {
if (accessCount <= 0) return 0;

// Frequency component: sigmoid of log1p(count) → 0.5..1.0
const freq = 1 / (1 + Math.exp(-Math.log1p(accessCount)));

// Recency component: exponential decay from last access
const ageDays = Math.max(0, (Date.now() - lastAccessMs) / 86_400_000);
const recency = Math.exp(-decayRate * ageDays);

return freq * recency;
}

// ============================================================================
// AccessTracker Class
// ============================================================================
Expand Down
50 changes: 44 additions & 6 deletions src/retriever.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { Embedder } from "./embedder.js";
import {
AccessTracker,
computeEffectiveHalfLife,
computeHotnessScore,
parseAccessMetadata,
} from "./access-tracker.js";
import { filterNoise } from "./noise-filter.js";
Expand Down Expand Up @@ -89,6 +90,10 @@ export interface RetrievalConfig {
* Queries containing these prefixes (e.g. "proj:AIF") will use BM25-only + mustContain
* to avoid semantic false positives from vector search. */
tagPrefixes: string[];
/** Hotness blend weight. Blends access-frequency hotness into final score.
* Formula: final = score * (1-alpha) + hotness * alpha.
* 0 = disabled (default), 0.15 = recommended. */
hotnessWeight: number;
}

export interface RetrievalContext {
Expand Down Expand Up @@ -131,6 +136,7 @@ export const DEFAULT_RETRIEVAL_CONFIG: RetrievalConfig = {
reinforcementFactor: 0.5,
maxHalfLifeMultiplier: 3,
tagPrefixes: ["proj", "env", "team", "scope"],
hotnessWeight: 0,
};

// ============================================================================
Expand Down Expand Up @@ -462,9 +468,10 @@ export class MemoryRetriever {
const lifecycleRanked = this.decayEngine
? this.applyDecayBoost(hardFiltered)
: this.applyTimeDecay(hardFiltered);
const hotnessBlended = this.applyHotnessBlend(lifecycleRanked);
const denoised = this.config.filterNoise
? filterNoise(lifecycleRanked, r => r.entry.text)
: lifecycleRanked;
? filterNoise(hotnessBlended, r => r.entry.text)
: hotnessBlended;

// MMR deduplication: avoid top-k filled with near-identical memories
const deduplicated = this.applyMMRDiversity(denoised);
Expand Down Expand Up @@ -521,10 +528,11 @@ export class MemoryRetriever {
const lifecycleRanked = this.decayEngine
? this.applyDecayBoost(hardFiltered)
: this.applyTimeDecay(hardFiltered);
const hotnessBlended = this.applyHotnessBlend(lifecycleRanked);

const denoised = this.config.filterNoise
? filterNoise(lifecycleRanked, r => r.entry.text)
: lifecycleRanked;
? filterNoise(hotnessBlended, r => r.entry.text)
: hotnessBlended;

const deduplicated = this.applyMMRDiversity(denoised);
return deduplicated.slice(0, limit);
Expand Down Expand Up @@ -589,11 +597,12 @@ export class MemoryRetriever {
const lifecycleRanked = this.decayEngine
? this.applyDecayBoost(hardFiltered)
: this.applyTimeDecay(hardFiltered);
const hotnessBlended = this.applyHotnessBlend(lifecycleRanked);

// Filter noise
const denoised = this.config.filterNoise
? filterNoise(lifecycleRanked, r => r.entry.text)
: lifecycleRanked;
? filterNoise(hotnessBlended, r => r.entry.text)
: hotnessBlended;

// MMR deduplication: avoid top-k filled with near-identical memories
const deduplicated = this.applyMMRDiversity(denoised);
Expand Down Expand Up @@ -1010,6 +1019,35 @@ export class MemoryRetriever {
return decayed.sort((a, b) => b.score - a.score);
}

/**
* Blend access-frequency hotness into retrieval scores.
*
* Formula: final = score * (1 - alpha) + hotness * alpha
* where hotness = sigmoid(log1p(accessCount)) * exp(-decayRate * ageDays)
*
* Inspired by OpenViking memory_lifecycle.py hotness scoring.
* No-op when hotnessWeight is 0 or AccessTracker is absent.
*/
private applyHotnessBlend(results: RetrievalResult[]): RetrievalResult[] {
const raw = this.config.hotnessWeight;
const alpha = Math.min(1, Math.max(0, Number.isFinite(raw) ? raw : 0));
if (alpha <= 0 || !this.accessTracker) return results;

const blended = results.map((r) => {
const { accessCount, lastAccessedAt } = parseAccessMetadata(r.entry.metadata);
const hotness = computeHotnessScore(
accessCount,
lastAccessedAt || r.entry.timestamp,
);
return {
...r,
score: clamp01(r.score * (1 - alpha) + hotness * alpha, 0),
};
});

return blended.sort((a, b) => b.score - a.score);
}

/**
* Apply lifecycle-aware score adjustment (decay + tier floors).
*
Expand Down
50 changes: 50 additions & 0 deletions test/hotness-blend.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { computeHotnessScore } from "../src/access-tracker.js";

describe("computeHotnessScore", () => {
it("returns 0 for zero accesses", () => {
assert.equal(computeHotnessScore(0, Date.now()), 0);
});

it("returns positive score for accessed memories", () => {
const score = computeHotnessScore(5, Date.now());
assert.ok(score > 0);
assert.ok(score <= 1);
});

it("higher access count yields higher score", () => {
const now = Date.now();
const low = computeHotnessScore(1, now);
const mid = computeHotnessScore(5, now);
const high = computeHotnessScore(50, now);
assert.ok(low < mid, `low(${low}) should be < mid(${mid})`);
assert.ok(mid < high, `mid(${mid}) should be < high(${high})`);
});

it("recent access yields higher score than old access", () => {
const now = Date.now();
const recent = computeHotnessScore(5, now);
const weekAgo = computeHotnessScore(5, now - 7 * 86_400_000);
const monthAgo = computeHotnessScore(5, now - 30 * 86_400_000);
assert.ok(recent > weekAgo, `recent(${recent}) > weekAgo(${weekAgo})`);
assert.ok(weekAgo > monthAgo, `weekAgo(${weekAgo}) > monthAgo(${monthAgo})`);
});

it("decays to near-zero for very old accesses", () => {
const score = computeHotnessScore(5, Date.now() - 365 * 86_400_000);
assert.ok(score < 0.01, `score(${score}) should be near zero for year-old access`);
});

it("caps at 1.0 even with extreme access counts", () => {
const score = computeHotnessScore(10_000, Date.now());
assert.ok(score <= 1.0);
});

it("respects custom decay rate", () => {
const now = Date.now();
const fast = computeHotnessScore(5, now - 7 * 86_400_000, 0.5); // fast decay
const slow = computeHotnessScore(5, now - 7 * 86_400_000, 0.01); // slow decay
assert.ok(slow > fast, `slow decay(${slow}) > fast decay(${fast})`);
});
});
Loading