| """Memory subsystem — cognitively-grounded, HippoRAG-inspired. |
| |
| Layers: |
| * Episodic store — timestamped events, embedding + cues + importance + decay. |
| * Semantic store — consolidated rules (via REFLECT). |
| * Associative graph — NetworkX DiGraph over episodic ids with typed edges. |
| |
| Retrieval: |
| * Extract query cues → seed the graph → Personalized PageRank → rank by PPR + embedding sim + ACT-R activation. |
| * Retrieval bumps importance (activation spreading proxy). |
| |
| Forgetting: |
| * ACT-R style decay per step; retrieval resets decay clock. |
| * Soft-delete via low activation threshold. |
| |
| References: |
| HippoRAG (arXiv:2405.14831), Generative Agents (Park et al.), |
| A-MEM, GAM (arXiv:2604.12285), ACT-R (ACM HAI 2025). |
| """ |
|
|
| from __future__ import annotations |
|
|
| import math |
| import uuid |
| from dataclasses import dataclass, field |
| from typing import TYPE_CHECKING |
|
|
| import networkx as nx |
|
|
|
|
| @dataclass |
| class EpisodicMemory: |
| memory_id: str |
| step: int |
| content: str |
| cues: list = field(default_factory=list) |
| importance: float = 0.5 |
| embedding: list | None = None |
| links: list = field(default_factory=list) |
|
|
|
|
| @dataclass |
| class SemanticMemory: |
| memory_id: str |
| rule: str |
| derived_from: list |
| created_step: int |
| applications: int = 0 |
|
|
|
|
| class RelationType: |
| CONTRADICTS = "contradicts" |
| SUPPORTS = "supports" |
| FOLLOWS = "follows" |
| REFERENCES = "references" |
|
|
|
|
| if TYPE_CHECKING: |
| import numpy as np |
|
|
|
|
| |
| |
| |
|
|
|
|
| class Embedder: |
| """Thin wrapper around sentence-transformers with a deterministic fallback. |
| |
| The fallback (hash-based pseudo-embedding) is for unit tests / offline dev. |
| Real runs load all-MiniLM-L6-v2 (90MB, fast on CPU). |
| |
| The loaded model is cached at module level (`_MODEL_CACHE`) so every |
| Embedder() instance shares the same underlying model — no repeated 90MB |
| reloads across env.reset() calls. |
| """ |
|
|
| def __init__(self, model_name: str = "all-MiniLM-L6-v2", use_real: bool = True): |
| self.model_name = model_name |
| self.use_real = use_real |
| self._dim = 384 |
|
|
| @property |
| def _model(self): |
| return _MODEL_CACHE.get(self.model_name) |
|
|
| def _load(self): |
| if self.model_name in _MODEL_CACHE: |
| return |
| if not self.use_real: |
| return |
| try: |
| from sentence_transformers import SentenceTransformer |
|
|
| _MODEL_CACHE[self.model_name] = SentenceTransformer(self.model_name) |
| except ImportError: |
| self.use_real = False |
|
|
| def encode(self, text: str) -> list[float]: |
| self._load() |
| if self._model is not None: |
| import numpy as np |
|
|
| vec = self._model.encode(text, normalize_embeddings=True) |
| return np.asarray(vec, dtype=float).tolist() |
| return self._hash_embedding(text) |
|
|
| def _hash_embedding(self, text: str) -> list[float]: |
| """Deterministic pseudo-embedding for offline testing. Not semantic.""" |
| import hashlib |
|
|
| h = hashlib.sha256(text.encode("utf-8")).digest() |
| raw = [(b / 255.0) * 2 - 1 for b in h][: self._dim] |
| while len(raw) < self._dim: |
| raw.extend(raw[: self._dim - len(raw)]) |
| norm = math.sqrt(sum(x * x for x in raw)) or 1.0 |
| return [x / norm for x in raw] |
|
|
|
|
| |
| _MODEL_CACHE: dict[str, object] = {} |
|
|
|
|
| def cosine(a: list[float], b: list[float]) -> float: |
| dot = sum(x * y for x, y in zip(a, b)) |
| return dot |
|
|
|
|
| |
| |
| |
|
|
|
|
| _STOP = { |
| "the", "a", "an", "and", "or", "but", "of", "in", "on", "at", "to", "for", |
| "with", "by", "is", "are", "was", "were", "be", "been", "being", "this", |
| "that", "these", "those", "i", "you", "he", "she", "it", "we", "they", |
| "our", "their", "my", "your", "his", "her", "its", "not", "no", "yes", |
| } |
|
|
|
|
| def extract_cues(text: str, max_cues: int = 8) -> list[str]: |
| """Cheap cue extractor: capitalised tokens + content nouns. |
| |
| This is a stand-in for proper OpenIE. Good enough for a training env where |
| stakeholder_ids and key concepts tend to be explicit. |
| """ |
| words = [w.strip(".,;:!?()[]\"'") for w in text.split()] |
| cues: list[str] = [] |
| seen: set[str] = set() |
| for w in words: |
| if not w or w.lower() in _STOP or len(w) <= 2: |
| continue |
| key = w.lower() |
| if key in seen: |
| continue |
| seen.add(key) |
| |
| if w[0].isupper() or any(c.isdigit() for c in w): |
| cues.append(key) |
| elif len(cues) < max_cues // 2: |
| cues.append(key) |
| if len(cues) >= max_cues: |
| break |
| return cues |
|
|
|
|
| |
| |
| |
|
|
|
|
| @dataclass |
| class ActRRecord: |
| """Per-memory ACT-R state. Activation = base_level + assoc + noise. |
| |
| Simplified ACT-R: base_level = log(sum t_i^-d) across retrievals. |
| We keep per-retrieval timestamps and recompute on demand. |
| """ |
|
|
| retrievals: list[int] = field(default_factory=list) |
| created_step: int = 0 |
| decay_rate: float = 0.5 |
|
|
| def activation(self, current_step: int) -> float: |
| lags = [max(1, current_step - t) for t in self.retrievals] |
| if not lags: |
| lags = [max(1, current_step - self.created_step)] |
| base = math.log(sum(lag ** -self.decay_rate for lag in lags)) |
| return base |
|
|
| def bump(self, step: int): |
| self.retrievals.append(step) |
|
|
|
|
| |
| |
| |
|
|
|
|
| class MemoryStore: |
| """Unified episodic + semantic store with HippoRAG-style retrieval.""" |
|
|
| def __init__(self, embedder: Embedder | None = None, forget_threshold: float = -5.0): |
| self.embedder = embedder or Embedder() |
| self.episodic: dict[str, EpisodicMemory] = {} |
| self.semantic: dict[str, SemanticMemory] = {} |
| self.graph = nx.DiGraph() |
| self.actr: dict[str, ActRRecord] = {} |
| self.current_step = 0 |
| self.forget_threshold = forget_threshold |
|
|
| |
| |
| |
|
|
| def tick(self, step: int): |
| self.current_step = step |
|
|
| |
| |
| |
|
|
| def write_episode( |
| self, |
| step: int, |
| content: str, |
| cues: list[str] | None = None, |
| importance: float = 0.5, |
| ) -> EpisodicMemory: |
| mid = f"ep_{uuid.uuid4().hex[:8]}" |
| cues = cues if cues is not None else extract_cues(content) |
| embedding = self.embedder.encode(content) |
| mem = EpisodicMemory( |
| memory_id=mid, |
| step=step, |
| content=content, |
| cues=cues, |
| importance=importance, |
| embedding=embedding, |
| ) |
| self.episodic[mid] = mem |
| self.actr[mid] = ActRRecord(created_step=step) |
| self._index_into_graph(mem) |
| return mem |
|
|
| def write_semantic( |
| self, |
| step: int, |
| rule: str, |
| derived_from: list[str], |
| ) -> SemanticMemory: |
| mid = f"sem_{uuid.uuid4().hex[:8]}" |
| mem = SemanticMemory( |
| memory_id=mid, |
| rule=rule, |
| derived_from=derived_from, |
| created_step=step, |
| ) |
| self.semantic[mid] = mem |
| |
| self.graph.add_node(mid, kind="semantic") |
| for ep_id in derived_from: |
| if ep_id in self.episodic: |
| self.graph.add_edge(mid, ep_id, rel="derived_from", weight=1.0) |
| self.graph.add_edge(ep_id, mid, rel="consolidated_into", weight=0.5) |
| return mem |
|
|
| def _index_into_graph(self, mem: EpisodicMemory): |
| self.graph.add_node(mem.memory_id, kind="episodic") |
| |
| for cue in mem.cues: |
| cue_node = f"cue::{cue}" |
| if not self.graph.has_node(cue_node): |
| self.graph.add_node(cue_node, kind="cue") |
| self.graph.add_edge(mem.memory_id, cue_node, rel="has_cue", weight=1.0) |
| self.graph.add_edge(cue_node, mem.memory_id, rel="cue_of", weight=1.0) |
|
|
| |
| |
| |
|
|
| def link(self, a: str, b: str, relation: RelationType) -> bool: |
| if a not in self.graph or b not in self.graph: |
| return False |
| self.graph.add_edge(a, b, rel=relation.value, weight=1.0) |
| |
| if a in self.episodic and b not in self.episodic[a].links: |
| self.episodic[a].links.append(b) |
| return True |
|
|
| def forget(self, memory_id: str) -> bool: |
| removed = False |
| if memory_id in self.episodic: |
| del self.episodic[memory_id] |
| removed = True |
| if memory_id in self.semantic: |
| del self.semantic[memory_id] |
| removed = True |
| if memory_id in self.actr: |
| del self.actr[memory_id] |
| if self.graph.has_node(memory_id): |
| self.graph.remove_node(memory_id) |
| return removed |
|
|
| def sweep_decayed(self) -> list[str]: |
| """ACT-R: drop memories whose activation dropped below threshold.""" |
| dropped: list[str] = [] |
| for mid in list(self.episodic.keys()): |
| rec = self.actr.get(mid) |
| if rec is None: |
| continue |
| imp = self.episodic[mid].importance |
| act = rec.activation(self.current_step) + imp |
| if act < self.forget_threshold: |
| self.forget(mid) |
| dropped.append(mid) |
| return dropped |
|
|
| |
| |
| |
|
|
| def query( |
| self, |
| query_text: str, |
| cues: list[str] | None = None, |
| top_k: int = 5, |
| alpha_ppr: float = 0.5, |
| alpha_sim: float = 0.35, |
| alpha_actr: float = 0.15, |
| ) -> list[EpisodicMemory | SemanticMemory]: |
| """Retrieve top-k memories blending PPR, embedding sim, and ACT-R activation.""" |
| if not self.episodic and not self.semantic: |
| return [] |
|
|
| query_cues = cues if cues else extract_cues(query_text) |
| query_embedding = self.embedder.encode(query_text) |
|
|
| |
| seed_nodes = [f"cue::{c}" for c in query_cues if self.graph.has_node(f"cue::{c}")] |
| ppr_scores: dict[str, float] = {} |
| if seed_nodes: |
| personalization = {n: 1.0 / len(seed_nodes) for n in seed_nodes} |
| try: |
| ppr = nx.pagerank( |
| self.graph, |
| alpha=0.85, |
| personalization=personalization, |
| max_iter=50, |
| tol=1e-4, |
| ) |
| ppr_scores = { |
| n: s for n, s in ppr.items() |
| if n in self.episodic or n in self.semantic |
| } |
| except nx.PowerIterationFailedConvergence: |
| ppr_scores = {} |
|
|
| |
| scored: list[tuple[float, EpisodicMemory | SemanticMemory]] = [] |
| all_memories: list[EpisodicMemory | SemanticMemory] = ( |
| list(self.episodic.values()) + list(self.semantic.values()) |
| ) |
| for mem in all_memories: |
| mid = mem.memory_id |
| |
| if isinstance(mem, EpisodicMemory) and mem.embedding is not None: |
| sim = cosine(query_embedding, mem.embedding) |
| else: |
| text = mem.rule if isinstance(mem, SemanticMemory) else mem.content |
| mem_embed = self.embedder.encode(text) |
| sim = cosine(query_embedding, mem_embed) |
| |
| ppr = ppr_scores.get(mid, 0.0) |
| |
| rec = self.actr.get(mid) |
| act = rec.activation(self.current_step) if rec else 0.0 |
| |
| score = alpha_sim * sim + alpha_ppr * ppr * 10 + alpha_actr * act * 0.1 |
| |
| importance = ( |
| mem.importance if isinstance(mem, EpisodicMemory) else 0.6 |
| ) |
| score += 0.1 * importance |
| scored.append((score, mem)) |
|
|
| scored.sort(key=lambda t: t[0], reverse=True) |
| top = [m for _, m in scored[:top_k]] |
| |
| for m in top: |
| rec = self.actr.get(m.memory_id) |
| if rec is not None: |
| rec.bump(self.current_step) |
| |
| if isinstance(m, EpisodicMemory): |
| m.importance = min(1.0, m.importance + 0.02) |
| elif isinstance(m, SemanticMemory): |
| m.applications += 1 |
| return top |
|
|
| |
| |
| |
|
|
| def stats(self) -> dict[str, int]: |
| return { |
| "episodic": len(self.episodic), |
| "semantic": len(self.semantic), |
| "graph_nodes": self.graph.number_of_nodes(), |
| "graph_edges": self.graph.number_of_edges(), |
| } |
|
|