feat(memory): implement retention decay model with strength-based ordering

- Add retention model constants (45-day half-life, 6.0 safety factor)
- Add TYPE_MAX caps (feedback:10, decision:10, project:8, reference:6)
- Add strength calculation: initialStrength × 2^(-age/halfLife)
- Integrate strength-based sorting into enforceLongTermLimits
- Safety-critical entries bypass type caps
- Add fields: retentionClock, reinforcementCount, userImportance, safetyCritical
This commit is contained in:
Ralph Chang
2026-04-29 14:18:51 +08:00
parent bb7e4e2927
commit 4097815f3e
4 changed files with 348 additions and 56 deletions
+5
View File
@@ -20,6 +20,11 @@ export type LongTermMemoryEntry = {
promotionAttempts?: number;
lastPromotionAttemptAt?: string;
lastPromotionFailureReason?: string;
retentionClock?: number; // Unix timestamp when retention started
reinforcementCount?: number; // Number of times this memory was reinforced
lastReinforcedAt?: number; // Unix timestamp of last reinforcement
userImportance?: "low" | "normal" | "high";
safetyCritical?: boolean;
};
export type WorkspaceMemoryStore = {
+118 -18
View File
@@ -12,6 +12,87 @@ const MIN_ENVELOPE_LENGTH = 80;
const MIGRATION_ID = "2026-04-26-p0-cleanup";
const QUALITY_CLEANUP_MIGRATION_ID = "2026-04-28-quality-cleanup";
// Retention decay model constants (v1.5)
const BASE_HALF_LIFE_DAYS = 45;
const REINFORCEMENT_HALFLIFE_FACTOR = 0.85;
const REINFORCEMENT_MAX_COUNT = 6;
const REINFORCEMENT_MIN_INTERVAL_MS = 60 * 60 * 1000; // 1 hour
const WORKSPACE_DORMANT_AFTER_DAYS = 14;
const DORMANT_DECAY_MULTIPLIER = 0.25;
const TYPE_FACTOR = {
reference: 1.0,
project: 1.25,
feedback: 2.25,
decision: 2.5,
} as const;
const SOURCE_FACTOR = {
compaction: 1.0,
manual: 1.4,
explicit: 2.0,
} as const;
const USER_IMPORTANCE_FACTOR = {
low: 0.7,
normal: 1.0,
high: 1.5,
} as const;
const SAFETY_CRITICAL_FACTOR = 6.0;
const TYPE_MAX = {
feedback: 10,
decision: 10,
project: 8,
reference: 6,
} as const;
export function calculateInitialStrength(memory: LongTermMemoryEntry): number {
const typeFactor = TYPE_FACTOR[memory.type] ?? 1.0;
const sourceFactor = SOURCE_FACTOR[memory.source] ?? 1.0;
const importanceFactor = USER_IMPORTANCE_FACTOR[memory.userImportance ?? "normal"] ?? 1.0;
const safetyFactor = memory.safetyCritical ? SAFETY_CRITICAL_FACTOR : 1.0;
return typeFactor * sourceFactor * importanceFactor * safetyFactor;
}
export function calculateEffectiveHalfLife(memory: LongTermMemoryEntry): number {
const reinforcementCount = Math.min(
memory.reinforcementCount ?? 0,
REINFORCEMENT_MAX_COUNT,
);
const factor = Math.pow(REINFORCEMENT_HALFLIFE_FACTOR, reinforcementCount);
return BASE_HALF_LIFE_DAYS / factor;
}
export function calculateRetentionStrength(
memory: LongTermMemoryEntry,
now: number,
dormantDays: number,
): number {
const initialStrength = calculateInitialStrength(memory);
const effectiveHalfLife = calculateEffectiveHalfLife(memory);
// Use retentionClock if available, fallback to updatedAt.
const retentionStart = memory.retentionClock ?? memory.updatedAt;
const createdAtMs = new Date(retentionStart).getTime();
const ageMs = now - createdAtMs;
const ageDays = ageMs / (24 * 60 * 60 * 1000);
// Apply dormant boost to effective age.
let effectiveAgeDays = ageDays;
if (dormantDays > 0) {
// Dormant workspace ages faster.
effectiveAgeDays += dormantDays * DORMANT_DECAY_MULTIPLIER;
}
// Calculate strength using exponential decay.
const strength = initialStrength * Math.pow(2, -effectiveAgeDays / effectiveHalfLife);
return Math.max(0, strength);
}
export type MemoryConsolidationReason =
| "promoted"
| "absorbed_exact"
@@ -497,6 +578,9 @@ export function enforceLongTermLimits(entries: LongTermMemoryEntry[]): LongTermM
export function enforceLongTermLimitsWithAccounting(entries: LongTermMemoryEntry[]): LongTermLimitResult {
const now = Date.now();
// Store-level last activity tracking is not available yet; dormant handling
// will be wired in a later wave.
const dormantDays = 0;
const staleDropped: MemoryConsolidationEvent[] = [];
// Phase 1: filter active, prune by age
@@ -511,8 +595,9 @@ export function enforceLongTermLimitsWithAccounting(entries: LongTermMemoryEntry
}
const dedupeResult = dedupeLongTermEntriesWithAccounting(phase1);
const sorted = [...dedupeResult.kept].sort(compareLongTermMemoryForRetention);
const kept = sorted.slice(0, LONG_TERM_LIMITS.maxEntries);
const sorted = [...dedupeResult.kept].sort((a, b) => compareLongTermMemoryForRetention(a, b, now, dormantDays));
const capped = applyTypeMaxCaps(sorted);
const kept = capped.slice(0, LONG_TERM_LIMITS.maxEntries);
const keptIds = new Set(kept.map(entry => entry.id));
const capacityDropped = sorted
.filter(entry => !keptIds.has(entry.id))
@@ -526,6 +611,27 @@ export function enforceLongTermLimitsWithAccounting(entries: LongTermMemoryEntry
};
}
function applyTypeMaxCaps(entries: LongTermMemoryEntry[]): LongTermMemoryEntry[] {
const capped: LongTermMemoryEntry[] = [];
const typeCounts: Partial<Record<LongTermMemoryEntry["type"], number>> = {};
for (const entry of entries) {
if (entry.safetyCritical) {
capped.push(entry);
continue;
}
const count = typeCounts[entry.type] ?? 0;
const max = TYPE_MAX[entry.type] ?? Infinity;
if (count >= max) continue;
capped.push(entry);
typeCounts[entry.type] = count + 1;
}
return capped;
}
export function dedupeLongTermEntriesWithAccounting(entries: LongTermMemoryEntry[]): LongTermLimitResult {
const absorbed: MemoryConsolidationEvent[] = [];
const superseded: MemoryConsolidationEvent[] = [];
@@ -598,10 +704,16 @@ export function dedupeLongTermEntriesWithAccounting(entries: LongTermMemoryEntry
};
}
function compareLongTermMemoryForRetention(a: LongTermMemoryEntry, b: LongTermMemoryEntry): number {
const pA = priority(a);
const pB = priority(b);
if (pB !== pA) return pB - pA;
function compareLongTermMemoryForRetention(
a: LongTermMemoryEntry,
b: LongTermMemoryEntry,
now: number,
dormantDays: number,
): number {
const strengthA = calculateRetentionStrength(a, now, dormantDays);
const strengthB = calculateRetentionStrength(b, now, dormantDays);
if (strengthB !== strengthA) return strengthB - strengthA;
const sourceDiff = sourcePriority(b.source) - sourcePriority(a.source);
if (sourceDiff !== 0) return sourceDiff;
const createdDiff = new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
@@ -609,18 +721,6 @@ function compareLongTermMemoryForRetention(a: LongTermMemoryEntry, b: LongTermMe
return a.id.localeCompare(b.id);
}
function priority(entry: LongTermMemoryEntry): number {
const typeWeight = {
feedback: 400,
decision: 300,
project: 200,
reference: 100,
}[entry.type];
const sourceWeight = entry.source === "explicit" ? 1000 : 0;
return sourceWeight + typeWeight + entry.confidence * 10;
}
function wouldFit(
lines: string[],
nextLine: string,
+104 -8
View File
@@ -1422,9 +1422,9 @@ test("session.compacted clears compaction pending memory rejected by workspace e
try {
const now = new Date().toISOString();
await updateWorkspaceMemory(tmpDir, store => {
for (let i = 0; i < 28; i += 1) {
for (let i = 0; i < 10; i += 1) {
store.entries.push({
id: `mem_high_${i}`,
id: `mem_high_feedback_${i}`,
type: "feedback",
text: `High priority user feedback memory ${i} that should outrank low priority references.`,
source: "explicit",
@@ -1434,6 +1434,30 @@ test("session.compacted clears compaction pending memory rejected by workspace e
updatedAt: now,
});
}
for (let i = 0; i < 10; i += 1) {
store.entries.push({
id: `mem_high_decision_${i}`,
type: "decision",
text: `High priority decision memory ${i} that should outrank low priority references.`,
source: "explicit",
confidence: 1,
status: "active",
createdAt: now,
updatedAt: now,
});
}
for (let i = 0; i < 8; i += 1) {
store.entries.push({
id: `mem_high_project_${i}`,
type: "project",
text: `High priority project memory ${i} that should outrank low priority references.`,
source: "explicit",
confidence: 1,
status: "active",
createdAt: now,
updatedAt: now,
});
}
return store;
});
@@ -1476,9 +1500,9 @@ test("session.compacted keeps explicit pending memory rejected by workspace entr
try {
const now = new Date().toISOString();
await updateWorkspaceMemory(tmpDir, store => {
for (let i = 0; i < 28; i += 1) {
for (let i = 0; i < 10; i += 1) {
store.entries.push({
id: `mem_high_explicit_reject_${i}`,
id: `mem_high_explicit_reject_feedback_${i}`,
type: "feedback",
text: `Pinned high priority feedback for explicit reject ${i}.`,
source: "explicit",
@@ -1488,6 +1512,30 @@ test("session.compacted keeps explicit pending memory rejected by workspace entr
updatedAt: now,
});
}
for (let i = 0; i < 10; i += 1) {
store.entries.push({
id: `mem_high_explicit_reject_decision_${i}`,
type: "decision",
text: `Pinned high priority decision for explicit reject ${i}.`,
source: "explicit",
confidence: 1,
status: "active",
createdAt: now,
updatedAt: now,
});
}
for (let i = 0; i < 8; i += 1) {
store.entries.push({
id: `mem_high_explicit_reject_project_${i}`,
type: "project",
text: `Pinned high priority project for explicit reject ${i}.`,
source: "explicit",
confidence: 1,
status: "active",
createdAt: now,
updatedAt: now,
});
}
return store;
});
@@ -1531,9 +1579,9 @@ test("explicit capacity rejection records bounded retry metadata", async () => {
try {
const now = new Date().toISOString();
await updateWorkspaceMemory(tmpDir, store => {
for (let i = 0; i < 28; i += 1) {
for (let i = 0; i < 10; i += 1) {
store.entries.push({
id: `mem_high_bounded_reject_${i}`,
id: `mem_high_bounded_reject_feedback_${i}`,
type: "feedback",
text: `Pinned high priority feedback for bounded rejection ${i}.`,
source: "explicit",
@@ -1543,6 +1591,30 @@ test("explicit capacity rejection records bounded retry metadata", async () => {
updatedAt: now,
});
}
for (let i = 0; i < 10; i += 1) {
store.entries.push({
id: `mem_high_bounded_reject_decision_${i}`,
type: "decision",
text: `Pinned high priority decision for bounded rejection ${i}.`,
source: "explicit",
confidence: 1,
status: "active",
createdAt: now,
updatedAt: now,
});
}
for (let i = 0; i < 8; i += 1) {
store.entries.push({
id: `mem_high_bounded_reject_project_${i}`,
type: "project",
text: `Pinned high priority project for bounded rejection ${i}.`,
source: "explicit",
confidence: 1,
status: "active",
createdAt: now,
updatedAt: now,
});
}
return store;
});
@@ -1614,9 +1686,9 @@ test("session.compacted clears compaction pending memories when all rejected by
try {
const now = new Date().toISOString();
await updateWorkspaceMemory(tmpDir, store => {
for (let i = 0; i < 28; i += 1) {
for (let i = 0; i < 10; i += 1) {
store.entries.push({
id: `mem_high_all_rejected_${i}`,
id: `mem_high_all_rejected_feedback_${i}`,
type: "feedback",
text: `Pinned high priority feedback ${i} that keeps the workspace entry cap full.`,
source: "explicit",
@@ -1626,6 +1698,30 @@ test("session.compacted clears compaction pending memories when all rejected by
updatedAt: now,
});
}
for (let i = 0; i < 10; i += 1) {
store.entries.push({
id: `mem_high_all_rejected_decision_${i}`,
type: "decision",
text: `Pinned high priority decision ${i} that keeps the workspace entry cap full.`,
source: "explicit",
confidence: 1,
status: "active",
createdAt: now,
updatedAt: now,
});
}
for (let i = 0; i < 8; i += 1) {
store.entries.push({
id: `mem_high_all_rejected_project_${i}`,
type: "project",
text: `Pinned high priority project ${i} that keeps the workspace entry cap full.`,
source: "explicit",
confidence: 1,
status: "active",
createdAt: now,
updatedAt: now,
});
}
return store;
});
+121 -30
View File
@@ -19,6 +19,9 @@ import {
loadWorkspaceMemory,
saveWorkspaceMemory,
updateWorkspaceMemoryWithAccounting,
calculateInitialStrength,
calculateEffectiveHalfLife,
calculateRetentionStrength,
} from "../src/workspace-memory.ts";
import { redactCredentials } from "../src/redaction.ts";
import { assessMemoryQuality, isHardQualityReason, isProgressSnapshotViolation } from "../src/memory-quality.ts";
@@ -256,6 +259,104 @@ test("enforceLongTermLimits respects maxEntries limit", () => {
assert.ok(kept.length <= 28, `Should respect maxEntries. Got: ${kept.length}`);
});
test("calculateInitialStrength multiplies type, source, importance, and safety factors", () => {
const memory: LongTermMemoryEntry = {
...entry("strength", "Never store raw credentials", "reference"),
source: "explicit",
userImportance: "high",
safetyCritical: true,
};
assert.equal(calculateInitialStrength(memory), 18);
});
test("calculateEffectiveHalfLife clamps reinforcement count at configured maximum", () => {
const unreinforced = entry("unreinforced", "Use pnpm for this project");
const overReinforced: LongTermMemoryEntry = {
...entry("over-reinforced", "Use npm scripts for verification"),
reinforcementCount: 99,
};
assert.equal(calculateEffectiveHalfLife(unreinforced), 45);
assert.equal(
calculateEffectiveHalfLife(overReinforced),
45 / Math.pow(0.85, 6),
);
});
test("calculateRetentionStrength decays from retentionClock and applies dormant age", () => {
const now = Date.UTC(2026, 3, 29);
const fiveDaysAgo = now - 5 * 24 * 60 * 60 * 1000;
const memory: LongTermMemoryEntry = {
...entry("retention-clock", "Use TypeScript for plugin code", "reference"),
source: "compaction",
retentionClock: fiveDaysAgo,
updatedAt: new Date(now).toISOString(),
};
const expectedEffectiveAgeDays = 5 + 4 * 0.25;
const expected = Math.pow(2, -expectedEffectiveAgeDays / 45);
assert.equal(calculateRetentionStrength(memory, now, 4), expected);
});
test("calculateRetentionStrength falls back to updatedAt when retentionClock is absent", () => {
const now = Date.UTC(2026, 3, 29);
const memory: LongTermMemoryEntry = {
...entry("updated-at", "Prefer concise verification summaries", "feedback"),
source: "manual",
updatedAt: new Date(now - 45 * 24 * 60 * 60 * 1000).toISOString(),
};
const initialStrength = 2.25 * 1.4;
assert.equal(calculateRetentionStrength(memory, now, 0), initialStrength / 2);
});
test("enforceLongTermLimits orders entries by retention strength", () => {
const now = Date.now();
const freshFeedback: LongTermMemoryEntry = {
...entry("fresh-feedback", "User prefers direct concise answers", "feedback"),
source: "compaction",
updatedAt: new Date(now).toISOString(),
};
const oldExplicitReference: LongTermMemoryEntry = {
...entry("old-explicit-reference", "Legacy docs are at https://example.com/old", "reference"),
source: "explicit",
updatedAt: new Date(now - 365 * 24 * 60 * 60 * 1000).toISOString(),
};
const kept = enforceLongTermLimits([oldExplicitReference, freshFeedback]);
assert.deepEqual(kept.map(memory => memory.id), ["fresh-feedback", "old-explicit-reference"]);
});
test("enforceLongTermLimits applies per-type caps after strength sorting", () => {
const entries = Array.from({ length: 12 }, (_, i) =>
entry(`feedback_${i}`, `Unique user feedback preference ${i}`, "feedback")
);
const kept = enforceLongTermLimits(entries);
assert.equal(kept.length, 10);
assert.equal(kept.filter(memory => memory.type === "feedback").length, 10);
});
test("enforceLongTermLimits exempts safety-critical entries from type caps", () => {
const ordinaryFeedback = Array.from({ length: 12 }, (_, i) =>
entry(`feedback_${i}`, `Unique safe ordinary feedback preference ${i}`, "feedback")
);
const safetyCriticalFeedback: LongTermMemoryEntry = {
...entry("safety-feedback", "Never persist raw credentials in memory", "feedback"),
safetyCritical: true,
};
const kept = enforceLongTermLimits([safetyCriticalFeedback, ...ordinaryFeedback]);
assert.equal(kept.length, 11);
assert.ok(kept.some(memory => memory.id === "safety-feedback"));
assert.equal(kept.filter(memory => memory.type === "feedback" && !memory.safetyCritical).length, 10);
});
test("normalization ordering is deterministic for retention ties", () => {
const createdAt = "2026-04-28T00:00:00.000Z";
const a = {
@@ -378,16 +479,12 @@ test("dedupeLongTermEntriesWithAccounting does not report heuristic topic supers
test("enforceLongTermLimitsWithAccounting reports capacity drops", () => {
const now = new Date().toISOString();
const entries = Array.from({ length: LONG_TERM_LIMITS.maxEntries + 2 }, (_, i) => ({
id: `mem_${i}`,
type: "reference" as const,
text: `Unique low priority reference ${i}`,
source: "compaction" as const,
confidence: i < LONG_TERM_LIMITS.maxEntries ? 0.8 : 0.1,
status: "active" as const,
createdAt: now,
updatedAt: now,
}));
const entries = [
...Array.from({ length: 10 }, (_, i) => entry(`feedback_${i}`, `Capacity feedback ${i}`, "feedback")),
...Array.from({ length: 10 }, (_, i) => entry(`decision_${i}`, `Capacity decision ${i}`, "decision")),
...Array.from({ length: 8 }, (_, i) => entry(`project_${i}`, `Capacity project ${i}`, "project")),
...Array.from({ length: 2 }, (_, i) => entry(`reference_${i}`, `Capacity overflow reference ${i}`, "reference")),
].map(memory => ({ ...memory, createdAt: now, updatedAt: now }));
const result = enforceLongTermLimitsWithAccounting(entries);
@@ -445,21 +542,18 @@ test("normalizeWorkspaceMemoryWithAccounting redacts credentials before accounti
test("normalizeWorkspaceMemoryWithAccounting reports overflow capacity drops", async () => {
const root = "/repo";
const now = new Date().toISOString();
const entries = [
...Array.from({ length: 10 }, (_, i) => entry(`overflow_feedback_${i}`, `Overflow feedback ${i}`, "feedback")),
...Array.from({ length: 10 }, (_, i) => entry(`overflow_decision_${i}`, `Overflow decision ${i}`, "decision")),
...Array.from({ length: 8 }, (_, i) => entry(`overflow_project_${i}`, `Overflow project ${i}`, "project")),
entry("overflow_reference", "Overflow low strength reference", "reference"),
].map(memory => ({ ...memory, createdAt: now, updatedAt: now }));
const store: WorkspaceMemoryStore = {
version: 1,
workspace: { root, key: "abc" },
limits: { maxRenderedChars: LONG_TERM_LIMITS.maxRenderedChars, maxEntries: LONG_TERM_LIMITS.maxEntries },
migrations: ["2026-04-26-p0-cleanup"],
entries: Array.from({ length: LONG_TERM_LIMITS.maxEntries + 1 }, (_, i) => ({
id: `overflow_${i}`,
type: "reference" as const,
text: `Overflow reference ${i}`,
source: "compaction" as const,
confidence: i < LONG_TERM_LIMITS.maxEntries ? 0.8 : 0.1,
status: "active" as const,
createdAt: now,
updatedAt: now,
})),
entries,
updatedAt: now,
};
@@ -506,16 +600,13 @@ test("updateWorkspaceMemoryWithAccounting emits accounting events for persisted
try {
const now = new Date().toISOString();
const result = await updateWorkspaceMemoryWithAccounting(root, store => {
store.entries.push(...Array.from({ length: LONG_TERM_LIMITS.maxEntries + 1 }, (_, i) => ({
id: `persisted_${i}`,
type: "reference" as const,
text: `Persisted accounting reference ${i}`,
source: "compaction" as const,
confidence: i < LONG_TERM_LIMITS.maxEntries ? 0.8 : 0.1,
status: "active" as const,
createdAt: now,
updatedAt: now,
})));
store.entries.push(
...Array.from({ length: 10 }, (_, i) => entry(`persisted_feedback_${i}`, `Persisted feedback ${i}`, "feedback")),
...Array.from({ length: 10 }, (_, i) => entry(`persisted_decision_${i}`, `Persisted decision ${i}`, "decision")),
...Array.from({ length: 8 }, (_, i) => entry(`persisted_project_${i}`, `Persisted project ${i}`, "project")),
entry("persisted_reference", "Persisted low strength reference", "reference"),
);
store.entries = store.entries.map(memory => ({ ...memory, createdAt: now, updatedAt: now }));
return store;
});