File size: 9,426 Bytes
cee9d4a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 | """PersistentMemory: long-term memory that survives across sessions.
THE INNOVATION. Claude and GPT forget everything between conversations.
This module gives the Continuous Thought Engine TRUE long-term memory:
- A bank of "memory vectors" (d_model dimensional) stored on disk.
- At startup, the engine loads its memories and injects them into the
thought state — it "remembers" past interactions.
- During operation, salient thoughts are periodically written back to
the memory bank — the engine "learns" from experience.
- Memories are keyed by context (what was happening when the memory
formed), enabling associative recall.
This is the module that makes Fractus PERSONAL — it adapts to the user,
remembers preferences, and accumulates knowledge over time. No datacenter
needed; the memory lives on the user's machine.
Usage:
memory = PersistentMemory(d_model=128, path="~/.fractus/memory.pt")
engine.reset_thought()
engine.inject_memory(memory) # remember past context
# ... think ...
memory.consolidate(engine.thought_state, context="user asked about sorting")
memory.save()
"""
import os
import torch
import torch.nn as nn
class PersistentMemory:
"""A persistent bank of memory vectors.
Stores N memory slots, each (d_model,) + a text context label.
Memories are recalled via cosine similarity to the current thought state.
Args:
d_model: dimension of memory vectors (must match the engine).
max_memories: maximum number of stored memories (LRU eviction).
path: file path for persistence (load/save).
"""
def __init__(
self,
d_model: int = 128,
max_memories: int = 256,
path: str = None,
):
self.d_model = d_model
self.max_memories = max_memories
self.path = path
# Memory bank: vectors and their context labels.
self.vectors = [] # list of (d_model,) tensors
self.contexts = [] # list of strings
self.importance = [] # list of floats (higher = more salient)
# Load from disk if available.
if path and os.path.exists(path):
self.load()
def recall(self, query: torch.Tensor, top_k: int = 3) -> list:
"""Recall the top-k most relevant memories for a query.
Args:
query: (d_model,) the current thought state.
top_k: number of memories to recall.
Returns:
list of (context_label, similarity_score, vector) tuples.
"""
if not self.vectors:
return []
# Stack all memories and compute cosine similarity.
bank = torch.stack(self.vectors) # (N, d_model)
query_flat = query.flatten() # (d_model,)
# Cosine similarity.
sims = torch.nn.functional.cosine_similarity(
query_flat.unsqueeze(0), bank, dim=-1
) # (N,)
# Top-k.
k = min(top_k, len(self.vectors))
topk_sims, topk_idx = sims.topk(k)
results = []
for i in range(k):
idx = topk_idx[i].item()
results.append((
self.contexts[idx],
topk_sims[i].item(),
self.vectors[idx],
))
return results
def consolidate(
self,
thought_state: torch.Tensor,
context: str = "",
importance: float = 0.5,
):
"""Write a new memory from the current thought state.
Args:
thought_state: (1, 1, d_model) or (d_model,) the thought to remember.
context: a text label describing when/why this memory formed.
importance: salience score (higher = more likely to persist).
"""
vec = thought_state.flatten().detach().cpu()
if vec.shape[0] != self.d_model:
return # dimension mismatch, skip.
self.vectors.append(vec)
self.contexts.append(context)
self.importance.append(importance)
# LRU eviction: if over capacity, remove the least important memory.
if len(self.vectors) > self.max_memories:
min_idx = self.importance.index(min(self.importance))
self.vectors.pop(min_idx)
self.contexts.pop(min_idx)
self.importance.pop(min_idx)
def consolidate_if_salient(
self,
thought_state: torch.Tensor,
salience_score: float,
*,
context: str = "",
importance: float = 0.5,
threshold: float = 0.7,
min_distance: float = 0.1,
) -> bool:
"""Consolidate only if salient enough AND not a near-duplicate.
This gates automatic consolidation by the CTE's salience head: a
thought is only written to the memory bank when its salience score
clears ``threshold`` and it is not a near-duplicate (cosine >
``1 - min_distance``) of any existing memory.
Returns:
True if a memory was written, False otherwise.
"""
if salience_score < threshold:
return False
vec = thought_state.flatten().detach().cpu()
if vec.shape[0] != self.d_model:
return False
# De-duplication: skip if too close to an existing memory.
if self.vectors:
bank = torch.stack(self.vectors)
sim = torch.nn.functional.cosine_similarity(
vec.unsqueeze(0), bank, dim=-1)
if sim.max().item() > 1.0 - min_distance:
return False
self.consolidate(vec, context=context, importance=importance)
return True
def inject(self, engine, top_k: int = 3, blend: float = 0.05) -> float:
"""Inject recalled memories into the engine's thought state.
This is how the engine 'remembers' — past memories are added to
the current thought, biasing it toward relevant context.
Returns:
The L2 norm of the perturbation caused by the injection
(``||h_after - h_before||``). Zero if no memories were recalled.
This is the intrinsic salience signal for the salience head: a
large perturbation means the memory was relevant to the current
thought state.
Args:
engine: an object with a (B, 1, d_model) ``thought_state``.
top_k: number of memories to recall.
blend: fraction of the memory contribution blended in.
Default 0.05 (95% current thought + 5% memory) is tuned for
continuous per-tick injection rather than one-shot resets.
"""
if not self.vectors:
return 0.0
thought = engine.thought_state.flatten() # (d_model,)
recalled = self.recall(thought, top_k=top_k)
if recalled:
# Weighted sum of recalled memories, added to the thought.
total_weight = 0.0
memory_contribution = torch.zeros_like(thought)
for ctx, sim, vec in recalled:
weight = max(sim, 0.0) # only positive correlations
memory_contribution += weight * vec
total_weight += weight
if total_weight > 0:
memory_contribution /= total_weight
# Measure the perturbation BEFORE applying it.
# Δ = blend * memory_contribution (the additive part that shifts the thought).
delta = blend * memory_contribution.to(engine.thought_state.device)
perturbation = delta.norm().item() # L2 norm of the shift
# Blend: (1-blend) current thought + blend memory.
engine.thought_state[:, 0, :] = (
(1.0 - blend) * engine.thought_state[:, 0, :] +
blend * memory_contribution.to(engine.thought_state.device)
)
return perturbation
return 0.0
def save(self, path: str = None):
"""Save the memory bank to disk."""
path = path or self.path
if not path:
return
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
data = {
"vectors": [v.tolist() for v in self.vectors],
"contexts": self.contexts,
"importance": self.importance,
"d_model": self.d_model,
}
torch.save(data, path)
def load(self, path: str = None):
"""Load the memory bank from disk."""
path = path or self.path
if not path or not os.path.exists(path):
return
data = torch.load(path, weights_only=False)
self.d_model = data.get("d_model", self.d_model)
self.vectors = [torch.tensor(v, dtype=torch.float32) for v in data["vectors"]]
self.contexts = data["contexts"]
self.importance = data["importance"]
def clear(self):
"""Wipe all memories (factory reset)."""
self.vectors = []
self.contexts = []
self.importance = []
def __len__(self):
return len(self.vectors)
def summary(self) -> str:
"""Human-readable summary of stored memories."""
if not self.vectors:
return "Memory bank: empty"
lines = [f"Memory bank: {len(self.vectors)} memories"]
for i, (ctx, imp) in enumerate(zip(self.contexts, self.importance)):
lines.append(f" [{i}] imp={imp:.2f} {ctx[:60]}")
return "\n".join(lines)
|