From 4097815f3e1f3a9803dae88f009bb5c80a7450f0 Mon Sep 17 00:00:00 2001 From: Ralph Chang Date: Wed, 29 Apr 2026 14:18:51 +0800 Subject: [PATCH] feat(memory): implement retention decay model with strength-based ordering MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/types.ts | 5 ++ src/workspace-memory.ts | 136 +++++++++++++++++++++++++---- tests/plugin.test.ts | 112 ++++++++++++++++++++++-- tests/workspace-memory.test.ts | 151 ++++++++++++++++++++++++++------- 4 files changed, 348 insertions(+), 56 deletions(-) diff --git a/src/types.ts b/src/types.ts index 0a0c5e1..7688d53 100644 --- a/src/types.ts +++ b/src/types.ts @@ -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 = { diff --git a/src/workspace-memory.ts b/src/workspace-memory.ts index 12d74bb..82e33bc 100644 --- a/src/workspace-memory.ts +++ b/src/workspace-memory.ts @@ -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> = {}; + + 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, diff --git a/tests/plugin.test.ts b/tests/plugin.test.ts index daa7401..3494352 100644 --- a/tests/plugin.test.ts +++ b/tests/plugin.test.ts @@ -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; }); diff --git a/tests/workspace-memory.test.ts b/tests/workspace-memory.test.ts index bd48ee9..4d2f5b1 100644 --- a/tests/workspace-memory.test.ts +++ b/tests/workspace-memory.test.ts @@ -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; });