/** * Memory Evolution — lifecycle tracking for memory entries. * * Adds status, version, access tracking, supersede/consolidation links, * and decay scoring to each memory. Fields live inside the existing * metadata JSON so the LanceDB schema is unchanged. * * Design principles (from arXiv 2512.13564 survey): * - Archive-first, delete-never * - Supersede-on-conflict (old stays, new links to it) * - Composite decay = 0.2 time + 0.3 frequency + 0.5 importance (base) * With emotion: 0.15 time + 0.25 frequency + 0.45 importance + 0.15 emotionSalience */ import { parseEmotion, isEmotionScoringEnabled } from "./memory-schema.js"; import { computeUsageStatus } from "./usage-tracker.js"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- export type EvolutionStatus = "active" | "superseded" | "archived" | "consolidated" | "pending_review"; export interface EvolutionMetadata { status: EvolutionStatus; version: number; accessCount: number; lastAccessedAt: number | null; supersededBy: string | null; /** HP-1: Reverse link — this memory supersedes an older one */ supersedes: string | null; /** HP-1: Why this memory replaced the old one */ evolutionNote: string | null; consolidatedInto: string | null; /** HP-5: This memory contributed to a cross-memory pattern discovery */ contributedToPattern: string | null; sourceMemories: string[]; validFrom: number; validUntil: number | null; /** F3: Event actual time (ms) — distinct from memory creation time (validFrom). */ eventTime: number | null; } // --------------------------------------------------------------------------- // Defaults // --------------------------------------------------------------------------- export function defaultEvolution(now?: number): EvolutionMetadata { const ts = now ?? Date.now(); return { status: "active", version: 1, accessCount: 0, lastAccessedAt: null, supersededBy: null, supersedes: null, evolutionNote: null, consolidatedInto: null, contributedToPattern: null, sourceMemories: [], validFrom: ts, validUntil: null, eventTime: null, }; } // --------------------------------------------------------------------------- // Parse / Serialize // --------------------------------------------------------------------------- /** * Extract evolution metadata from a memory's metadata JSON string. * Returns sensible defaults when the field is absent (backward compat). */ export function parseEvolution(metadata: string | undefined, fallbackTimestamp?: number): EvolutionMetadata { if (!metadata) return defaultEvolution(fallbackTimestamp); try { const parsed = JSON.parse(metadata); const evo = parsed?.evolution; if (!evo) return defaultEvolution(fallbackTimestamp); return { status: evo.status ?? "active", version: evo.version ?? 1, accessCount: evo.accessCount ?? 0, lastAccessedAt: evo.lastAccessedAt ?? null, supersededBy: evo.supersededBy ?? null, supersedes: evo.supersedes ?? null, evolutionNote: evo.evolutionNote ?? null, consolidatedInto: evo.consolidatedInto ?? null, contributedToPattern: evo.contributedToPattern ?? null, sourceMemories: Array.isArray(evo.sourceMemories) ? evo.sourceMemories : [], validFrom: evo.validFrom ?? fallbackTimestamp ?? Date.now(), validUntil: evo.validUntil ?? null, eventTime: evo.eventTime ?? null, }; } catch { return defaultEvolution(fallbackTimestamp); } } /** * Patch evolution fields into an existing metadata JSON string. * Merges with existing evolution — does not wipe unset fields. */ export function patchEvolution( metadata: string | undefined, patch: Partial, ): string { let parsed: Record = {}; if (metadata) { try { parsed = JSON.parse(metadata); } catch { /* keep empty */ } } const existing = parsed.evolution as Partial | undefined; parsed.evolution = { ...existing, ...patch }; return JSON.stringify(parsed); } /** * patchEvolution 的对象版:给 patchMetadataBatch 的 patchFn 用(那条通道进出都是 * 已解析的 metadata 对象,不该为了复用字符串版而反复 stringify/parse)。 * 就地修改并返回同一对象,语义与 patchEvolution 一致(浅合并 evolution 子对象)。 */ export function patchEvolutionOnMeta( meta: Record, patch: Partial, ): Record { const existing = meta.evolution !== null && typeof meta.evolution === "object" && !Array.isArray(meta.evolution) ? (meta.evolution as Partial) : undefined; meta.evolution = { ...existing, ...patch }; return meta; } // --------------------------------------------------------------------------- // Predicates // --------------------------------------------------------------------------- /** Is this memory currently active (not superseded/archived/consolidated)? * HP-6: pending_review memories are also considered active for search purposes. */ export function isActiveMemory(metadata: string | undefined): boolean { const evo = parseEvolution(metadata); return evo.status === "active" || evo.status === "pending_review"; } // --------------------------------------------------------------------------- // Access Tracking // --------------------------------------------------------------------------- /** * Record a retrieval hit: increment accessCount and update lastAccessedAt. * Returns updated metadata JSON string. */ export function recordAccess(metadata: string | undefined): string { const evo = parseEvolution(metadata); return patchEvolution(metadata, { accessCount: evo.accessCount + 1, lastAccessedAt: Date.now(), }); } // --------------------------------------------------------------------------- // Supersede / Consolidate helpers // --------------------------------------------------------------------------- /** * Mark a memory as superseded by a newer memory. * Sets status, validUntil, and supersededBy link. */ export function buildSupersedeMetadata( oldMetadata: string | undefined, newMemoryId: string, ): string { return patchEvolution(oldMetadata, { status: "superseded", validUntil: Date.now(), supersededBy: newMemoryId, }); } /** * HP-1: Build evolution metadata for the NEW memory that supersedes an old one. * Sets supersedes link + optional evolution note (bidirectional with buildSupersedeMetadata). */ export function buildSupersedeMetadataForNew( newMetadata: string | undefined, oldMemoryId: string, evolutionNote?: string, ): string { return patchEvolution(newMetadata, { supersedes: oldMemoryId, evolutionNote: evolutionNote ?? null, }); } /** * HP-1: Trace the evolution chain for a memory. * Walks both directions: supersededBy (forward) and supersedes (backward). * Returns an ordered timeline from oldest to newest. */ export interface EvolutionTraceEntry { id: string; direction: "predecessor" | "self" | "successor"; status: EvolutionStatus; evolutionNote: string | null; validFrom: number; validUntil: number | null; } export async function traceEvolution( startId: string, getEntry: (id: string) => Promise<{ metadata?: string; timestamp?: number } | null>, maxDepth = 10, ): Promise { const predecessors: EvolutionTraceEntry[] = []; const successors: EvolutionTraceEntry[] = []; // Walk backward (supersedes chain) let currentId: string | null = startId; let depth = 0; const startEntry = await getEntry(startId); const startEvo = parseEvolution(startEntry?.metadata, startEntry?.timestamp); // First, walk backward via supersedes currentId = startEvo.supersedes; while (currentId && depth < maxDepth) { const entry = await getEntry(currentId); if (!entry) break; const evo = parseEvolution(entry.metadata, entry.timestamp); predecessors.unshift({ id: currentId, direction: "predecessor", status: evo.status, evolutionNote: evo.evolutionNote, validFrom: evo.validFrom, validUntil: evo.validUntil, }); currentId = evo.supersedes; depth++; } // Self const self: EvolutionTraceEntry = { id: startId, direction: "self", status: startEvo.status, evolutionNote: startEvo.evolutionNote, validFrom: startEvo.validFrom, validUntil: startEvo.validUntil, }; // Walk forward (supersededBy chain) currentId = startEvo.supersededBy; depth = 0; while (currentId && depth < maxDepth) { const entry = await getEntry(currentId); if (!entry) break; const evo = parseEvolution(entry.metadata, entry.timestamp); successors.push({ id: currentId, direction: "successor", status: evo.status, evolutionNote: evo.evolutionNote, validFrom: evo.validFrom, validUntil: evo.validUntil, }); currentId = evo.supersededBy; depth++; } return [...predecessors, self, ...successors]; } /** * Mark a memory as consolidated into a higher-level memory. * The original is kept (archive-first) but linked to the consolidated entry. */ export function buildConsolidatedMetadata( oldMetadata: string | undefined, consolidatedMemoryId: string, ): string { return patchEvolution(oldMetadata, { status: "consolidated", consolidatedInto: consolidatedMemoryId, }); } /** * Mark a memory as archived (low decay score for extended period). */ export function buildArchivedMetadata(oldMetadata: string | undefined): string { return patchEvolution(oldMetadata, { status: "archived" }); } /** * HP-6: Mark a memory as pending_review (low-confidence store). * Pending memories participate in search but are prioritized for distill processing. */ export function buildPendingReviewMetadata(metadata: string | undefined): string { return patchEvolution(metadata, { status: "pending_review" }); } /** HP-6: Check if a memory is pending review. */ export function isPendingReview(metadata: string | undefined): boolean { return parseEvolution(metadata).status === "pending_review"; } /** HP-6: Resolve pending → active (after distill re-evaluation confirms it). */ export function resolvePendingReview(metadata: string | undefined): string { return patchEvolution(metadata, { status: "active" }); } // --------------------------------------------------------------------------- // Decay Scoring // --------------------------------------------------------------------------- const TIME_HALF_LIFE_DAYS = 90; // Base weights (no emotion): 0.2 + 0.3 + 0.5 = 1.0 const TIME_WEIGHT_BASE = 0.2; const FREQUENCY_WEIGHT_BASE = 0.3; const IMPORTANCE_WEIGHT_BASE = 0.5; // Emotion-enabled weights: 0.15 + 0.25 + 0.45 + 0.15 = 1.0 const TIME_WEIGHT_EMO = 0.15; const FREQUENCY_WEIGHT_EMO = 0.25; const IMPORTANCE_WEIGHT_EMO = 0.45; const EMOTION_WEIGHT = 0.15; /** * Compute composite decay score (0–1, higher = more valuable to keep). * * Base: 0.2 × timeDecay + 0.3 × frequencyScore + 0.5 × importance * Emotion: 0.15 × time + 0.25 × freq + 0.45 × importance + 0.15 × emotionSalience * * Time decay: exponential with 90-day half-life. * Frequency: log(1 + accessCount) × recencyBoost, capped at 1. * Importance: as-stored (0–1). * Emotion salience: (|valence| + arousal) / 2 — composite mnemonic significance. * * @param metadata - Optional metadata JSON string for emotion extraction. * When absent or emotion flag off, falls back to base weights. */ export function computeDecayScore( evo: EvolutionMetadata, importance: number, now?: number, metadata?: string, ): number { return computeDecayScoreInternal(evo, importance, now, metadata, 1); } function computeDecayScoreInternal( evo: EvolutionMetadata, importance: number, now: number | undefined, metadata: string | undefined, frequencyFactor: number, ): number { const ts = now ?? Date.now(); // Time decay (Weibull-ish exponential) const daysSinceCreation = Math.max(0, (ts - evo.validFrom) / 86_400_000); const timeDecay = Math.pow(0.5, daysSinceCreation / TIME_HALF_LIFE_DAYS); // Frequency score const rawFreq = Math.log2(1 + evo.accessCount); const recencyBoost = evo.lastAccessedAt ? Math.pow(0.5, Math.max(0, (ts - evo.lastAccessedAt) / 86_400_000) / 30) // 30-day half-life for recency : 0.5; // never accessed → neutral const frequencyScore = Math.min(1, rawFreq * recencyBoost) * Math.max(0, Math.min(1, frequencyFactor)); const clampedImportance = Math.max(0, Math.min(1, importance)); // HP-emo: Emotion-aware decay scoring const useEmotion = isEmotionScoringEnabled() && !!metadata; if (useEmotion) { const emotion = parseEmotion(metadata); const salience = emotion?.salience ?? 0; return ( TIME_WEIGHT_EMO * timeDecay + FREQUENCY_WEIGHT_EMO * frequencyScore + IMPORTANCE_WEIGHT_EMO * clampedImportance + EMOTION_WEIGHT * salience ); } return ( TIME_WEIGHT_BASE * timeDecay + FREQUENCY_WEIGHT_BASE * frequencyScore + IMPORTANCE_WEIGHT_BASE * clampedImportance ); } function parseMetadataObject(metadata: string | undefined): Record { if (!metadata) return {}; try { const parsed: unknown = JSON.parse(metadata); return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as Record : {}; } catch { return {}; } } function readUsageCounts(meta: Record): { injection: number; use: number } { const usage = meta.usage; const usageObj = usage && typeof usage === "object" && !Array.isArray(usage) ? usage as Record : {}; return { injection: typeof meta.accessCount === "number" ? meta.accessCount : 0, use: typeof usageObj.useCount === "number" ? usageObj.useCount : 0, }; } function isPendingProspectiveReminder(meta: Record, now: number): boolean { const prospective = meta.prospective; if (!prospective || typeof prospective !== "object" || Array.isArray(prospective)) return false; const obj = prospective as Record; if (obj.status !== "pending") return false; if (typeof obj.expiresAt !== "string" || obj.expiresAt.length === 0) return true; const expiresAt = Date.parse(obj.expiresAt); return Number.isNaN(expiresAt) || expiresAt >= now; } function isUsageDecayProtected(meta: Record, importance: number, now: number): boolean { if (importance >= 0.95) return true; if (typeof meta.promotedTo === "string" && meta.promotedTo.trim().length > 0) return true; return isPendingProspectiveReminder(meta, now); } /** * GC-only usage-adjusted decay score. * * Cold means "repeatedly surfaced but never actually cited"; in that case the * frequency term is discounted instead of rewarding unhelpful exposure. This is * intentionally separate from computeDecayScore(), which is still used by * online retrieval ranking. */ export function computeUsageAdjustedDecayScore( evo: EvolutionMetadata, importance: number, now?: number, metadata?: string, ): number { const ts = now ?? Date.now(); const meta = parseMetadataObject(metadata); const counts = readUsageCounts(meta); const status = computeUsageStatus(counts.injection, counts.use); const factor = status === "cold" && !isUsageDecayProtected(meta, importance, ts) ? 0.2 : 1; return computeDecayScoreInternal(evo, importance, ts, metadata, factor); }