| """DMU Engine β Dynamic Memory Unit. |
| |
| The DMU ranks retrieved memories not merely by semantic similarity, but by a |
| composite **Memory Persistence Score (MPS)** that models how human-like recall |
| actually works: strong emotions anchor memories, time erodes them, and recent |
| events compete for attention. |
| |
| Mathematical Model |
| ------------------ |
| 1. TIME DECAY β Exponential Forgetting Curve |
| The raw retention of a memory at age t (seconds since creation) follows |
| the Ebbinghaus exponential decay: |
| |
| R(t) = e^( -Ξ» * t ) |
| |
| where Ξ» (lambda) is the base decay constant. By default Ξ» = 1.0 / HALF_LIFE |
| so that R(t) = 0.5 when t == HALF_LIFE seconds. |
| |
| However, emotional weight E β [0, 1] **modulates** the effective decay rate. |
| High-emotion memories fade more slowly. We model this as a dampened decay |
| coefficient: |
| |
| Ξ»_eff = Ξ»_base / ( 1 + Ξ± * E ) |
| |
| where Ξ± (alpha) is the EMOTIONAL_DAMPING factor. When E = 1 and Ξ± = 2, |
| the effective half-life triples β the memory persists three times longer. |
| |
| Therefore the **emotionally-weighted retention curve** is: |
| |
| R(t, E) = e^( -Ξ»_base * t / (1 + Ξ± * E) ) |
| |
| 2. COMPOSITE MEMORY PERSISTENCE SCORE (MPS) |
| For each candidate memory we compute a scalar score in [0, 1]: |
| |
| MPS = w_sim * S + w_time * R(t, E) + w_emo * E + w_rec * recency_bonus |
| |
| where: |
| S = semantic similarity to the query (from vector search, already in [0,1]) |
| R = time-decay retention explained above |
| E = emotional weight stored in memory metadata |
| recency_bonus = 1.0 if t < RECENCY_WINDOW else 0.0 |
| |
| The weights sum to 1.0 so that MPS is interpretable as a probability-like |
| relevance measure. |
| |
| 3. RETRIEVAL PIPELINE |
| Step A: Vector search returns top-K candidates by semantic similarity. |
| Step B: DMU re-ranks those candidates by MPS. |
| Step C: The final context window receives memories sorted by MPS descending. |
| |
| This architecture deliberately separates "what is semantically related" from |
| "what is psychologically salient" β mirroring how an INFJ cognitive style |
| prioritizes emotionally significant patterns over purely logical association. |
| """ |
|
|
| import math |
| import sqlite3 |
| from dataclasses import dataclass |
| from datetime import datetime, timezone |
| from pathlib import Path |
| from typing import Dict, List, Optional, Tuple |
|
|
| from infj_bot.core.config import DATA_DIR |
|
|
| DMU_DB = DATA_DIR / "dmu.db" |
|
|
| |
| |
| |
| HALF_LIFE_SECONDS: float = 86_400.0 |
|
|
| |
| |
| _LAMBDA_BASE: float = math.log(2) / HALF_LIFE_SECONDS |
|
|
| |
| |
| EMOTIONAL_DAMPING: float = 2.0 |
|
|
| |
| RECENCY_WINDOW_SECONDS: float = 3_600.0 |
|
|
| |
| WEIGHTS: Dict[str, float] = { |
| "semantic": 0.255, |
| "temporal": 0.2975, |
| "emotional": 0.2125, |
| "recency": 0.085, |
| "source_reliability": 0.15, |
| } |
| assert abs(sum(WEIGHTS.values()) - 1.0) < 1e-9, "MPS weights must sum to 1.0" |
|
|
| PROVENANCE_SCORES: Dict[str, float] = { |
| "user_explicit": 1.0, |
| "bot_inference": 0.6, |
| "conflicting_block": 0.2, |
| "corrupted_block": 0.1, |
| } |
|
|
|
|
| @dataclass |
| class MemoryCandidate: |
| """A memory retrieved from vector search, ready for DMU re-ranking.""" |
|
|
| memory_id: str |
| text: str |
| created_at: datetime |
| semantic_score: float |
| emotional_weight: float |
| source: str = "unknown" |
| provenance: str = "user_explicit" |
| source_reliability: float = 1.0 |
|
|
|
|
|
|
| class DynamicMemoryUnit: |
| """Ranks memories by psychological salience using time-decay + emotion.""" |
|
|
| def __init__(self, db_path: Optional[Path] = None): |
| self.db_path = str(db_path or DMU_DB) |
| Path(self.db_path).parent.mkdir(parents=True, exist_ok=True) |
| self._init_db() |
|
|
| def _init_db(self) -> None: |
| """SQLite backing store for audit trails and tuning telemetry.""" |
| with sqlite3.connect(self.db_path) as conn: |
| conn.execute(""" |
| CREATE TABLE IF NOT EXISTS dmu_rankings ( |
| id INTEGER PRIMARY KEY AUTOINCREMENT, |
| timestamp TEXT NOT NULL, |
| query TEXT, |
| memory_id TEXT NOT NULL, |
| semantic_score REAL, |
| retention_score REAL, |
| emotional_weight REAL, |
| recency_bonus REAL, |
| source_reliability REAL DEFAULT 1.0, |
| provenance TEXT DEFAULT 'user_explicit', |
| mps REAL NOT NULL |
| ) |
| """) |
| try: |
| conn.execute("ALTER TABLE dmu_rankings ADD COLUMN source_reliability REAL DEFAULT 1.0") |
| except sqlite3.OperationalError: |
| pass |
| try: |
| conn.execute("ALTER TABLE dmu_rankings ADD COLUMN provenance TEXT DEFAULT 'user_explicit'") |
| except sqlite3.OperationalError: |
| pass |
| conn.commit() |
|
|
| |
|
|
| @staticmethod |
| def _retention_curve(age_seconds: float, emotional_weight: float) -> float: |
| """ |
| Compute the emotionally-weighted retention score R(t, E). |
| |
| Formula: |
| Ξ»_eff = Ξ»_base / (1 + Ξ± * E) |
| R(t, E) = e^( -Ξ»_eff * t ) |
| |
| Parameters |
| ---------- |
| age_seconds : float |
| Time since memory creation (t β₯ 0). |
| emotional_weight : float |
| Emotional salience E β [0, 1]. Higher E β slower decay. |
| |
| Returns |
| ------- |
| float |
| Retention strength in [0, 1]. 1.0 = perfectly fresh, 0.0 = fully faded. |
| """ |
| if age_seconds < 0: |
| age_seconds = 0.0 |
|
|
| |
| |
| |
| lambda_eff = _LAMBDA_BASE / (1.0 + EMOTIONAL_DAMPING * emotional_weight) |
|
|
| |
| retention = math.exp(-lambda_eff * age_seconds) |
| return retention |
|
|
| @staticmethod |
| def _recency_bonus(age_seconds: float) -> float: |
| """ |
| Hard recency boost for very fresh memories. |
| |
| Returns 1.0 if age < RECENCY_WINDOW_SECONDS, else 0.0. |
| This models the cognitive "availability heuristic" β recent events |
| are disproportionately accessible regardless of semantic or emotional |
| content. |
| """ |
| return 1.0 if age_seconds < RECENCY_WINDOW_SECONDS else 0.0 |
|
|
| def compute_mps( |
| self, candidate: MemoryCandidate, now: Optional[datetime] = None, query: str = "" |
| ) -> float: |
| """ |
| Compute the Composite Memory Persistence Score (MPS) for a candidate. |
| |
| Formula: |
| MPS = w_sim * S + w_time * R(t, E) + w_emo * E + w_rec * recency + w_rel * reliability_term |
| |
| Each term is bounded in [0, 1], and weights sum to 1.0, so MPS β [0, 1]. |
| |
| Parameters |
| ---------- |
| candidate : MemoryCandidate |
| The memory to score. |
| now : datetime, optional |
| Reference time for age calculation. Defaults to UTC now. |
| |
| Returns |
| ------- |
| float |
| Composite persistence score in [0, 1]. Higher = more salient. |
| """ |
| if now is None: |
| now = datetime.now(timezone.utc) |
|
|
| |
| created = candidate.created_at |
| if created.tzinfo is None: |
| created = created.replace(timezone.utc) |
|
|
| age_seconds = (now - created).total_seconds() |
|
|
| |
| boost = 0.0 |
| if query: |
| try: |
| import re |
| query_words = set(re.findall(r"\b\w{3,}\b", query.lower())) |
| content_words = set(re.findall(r"\b\w{3,}\b", candidate.text.lower())) |
| overlap = query_words.intersection(content_words) |
| if query_words: |
| boost = 0.2 * (len(overlap) / len(query_words)) |
| except Exception: |
| pass |
| S = max(0.0, min(1.0, candidate.semantic_score + boost)) |
|
|
| |
| R = self._retention_curve(age_seconds, candidate.emotional_weight) |
|
|
| |
| E = max(0.0, min(1.0, candidate.emotional_weight)) |
|
|
| |
| recency = self._recency_bonus(age_seconds) |
|
|
| |
| prov_score = PROVENANCE_SCORES.get(candidate.provenance, 0.5) |
| reliability_term = prov_score * candidate.source_reliability |
|
|
| |
| mps = ( |
| WEIGHTS["semantic"] * S |
| + WEIGHTS["temporal"] * R |
| + WEIGHTS["emotional"] * E |
| + WEIGHTS["recency"] * recency |
| + WEIGHTS["source_reliability"] * reliability_term |
| ) |
|
|
| return round(max(0.0, min(1.0, mps)), 6) |
|
|
| |
|
|
| def rank_memories( |
| self, |
| candidates: List[MemoryCandidate], |
| query: str = "", |
| top_k: int = 10, |
| now: Optional[datetime] = None, |
| ) -> List[Tuple[MemoryCandidate, float]]: |
| """ |
| Re-rank a list of memory candidates by MPS and return top-k. |
| |
| Also logs the ranking telemetry to SQLite for later analysis. |
| """ |
| if now is None: |
| now = datetime.now(timezone.utc) |
|
|
| scored = [] |
| for cand in candidates: |
| mps = self.compute_mps(cand, now=now, query=query) |
| scored.append((cand, mps)) |
| self._log_ranking(query, cand, mps, now) |
|
|
| |
| scored.sort(key=lambda x: x[1], reverse=True) |
| return scored[:top_k] |
|
|
| def _log_ranking( |
| self, |
| query: str, |
| cand: MemoryCandidate, |
| mps: float, |
| now: datetime, |
| ) -> None: |
| """Persist scoring breakdown for audit and tuning.""" |
| age_seconds = ( |
| (now - cand.created_at).total_seconds() if cand.created_at else 0.0 |
| ) |
| retention = self._retention_curve(age_seconds, cand.emotional_weight) |
| recency = self._recency_bonus(age_seconds) |
|
|
| with sqlite3.connect(self.db_path) as conn: |
| conn.execute( |
| """ |
| INSERT INTO dmu_rankings |
| (timestamp, query, memory_id, semantic_score, retention_score, |
| emotional_weight, recency_bonus, source_reliability, provenance, mps) |
| VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) |
| """, |
| ( |
| now.isoformat(), |
| query, |
| cand.memory_id, |
| cand.semantic_score, |
| retention, |
| cand.emotional_weight, |
| recency, |
| cand.source_reliability, |
| cand.provenance, |
| mps, |
| ), |
| ) |
| conn.commit() |
|
|
| |
|
|
| def get_ranking_history(self, limit: int = 100) -> List[Dict]: |
| """Return recent DMU ranking records for debugging / audit.""" |
| with sqlite3.connect(self.db_path) as conn: |
| conn.row_factory = sqlite3.Row |
| rows = conn.execute( |
| "SELECT * FROM dmu_rankings ORDER BY timestamp DESC LIMIT ?", |
| (limit,), |
| ).fetchall() |
| return [dict(r) for r in rows] |
|
|
|
|
| |
|
|
|
|
| def rank_memory_entries( |
| entries, |
| query: str = "", |
| top_k: int = 10, |
| now: Optional[datetime] = None, |
| ) -> List[Tuple[MemoryCandidate, float]]: |
| """ |
| Adapter that bridges the Unified Memory Spine (MemoryEntry) with the DMU. |
| |
| The Unified Memory Spine already computes an internal DMU score in |
| `MemoryManager._calculate_dmu()`. This function takes the raw MemoryEntry |
| objects returned by `recall_sync()`, converts them into MemoryCandidates |
| using the *original semantic similarity* approximated from the stored score, |
| and applies the DMU re-ranking layer defined above. |
| |
| Parameters |
| ---------- |
| entries : List[MemoryEntry] |
| Results from `MemoryManager.recall_sync()`. |
| query : str |
| The original user query (used for telemetry only). |
| top_k : int |
| How many entries to return after re-ranking. |
| now : datetime, optional |
| Reference time for age calculation. |
| |
| Returns |
| ------- |
| List[Tuple[MemoryCandidate, float]] |
| Entries re-ranked by DMU Memory Persistence Score (MPS). |
| """ |
| candidates = [] |
| for entry in entries: |
| |
| |
| |
| ts = entry.event.timestamp |
| (now or datetime.now(timezone.utc) - ts).total_seconds() |
| emotional = float( |
| entry.metadata.get( |
| "emotion_intensity", entry.metadata.get("emotional", 0.5) |
| ) |
| ) |
| provenance = entry.metadata.get("provenance", "user_explicit") |
| source_reliability = float(entry.metadata.get("source_reliability", 1.0)) |
| |
| approx_semantic = min(1.0, entry.score * 1.2) |
|
|
| cand = MemoryCandidate( |
| memory_id=entry.unified_id, |
| text=entry.event.content, |
| created_at=ts, |
| semantic_score=approx_semantic, |
| emotional_weight=emotional, |
| source=entry.metadata.get("type", "unknown"), |
| provenance=provenance, |
| source_reliability=source_reliability, |
| ) |
| candidates.append(cand) |
|
|
| dmu = get_dmu() |
| return dmu.rank_memories(candidates, query=query, top_k=top_k, now=now) |
|
|
|
|
| def format_ranked_entries(ranked: List[Tuple[MemoryCandidate, float]]) -> str: |
| """Convert DMU-ranked entries into the string format expected by the prompt builder.""" |
| |
| chronological = sorted(ranked, key=lambda x: x[0].created_at) |
| |
| lines = [] |
| for cand, mps in chronological: |
| lines.append(f"[{cand.source}] [provenance: {cand.provenance}] (salience: {mps:.2f})\n{cand.text}") |
| return "\n---\n".join(lines) |
|
|
|
|
| |
|
|
| _default_dmu: Optional[DynamicMemoryUnit] = None |
|
|
|
|
| def get_dmu() -> DynamicMemoryUnit: |
| """Return the singleton DynamicMemoryUnit instance.""" |
| global _default_dmu |
| if _default_dmu is None: |
| _default_dmu = DynamicMemoryUnit() |
| return _default_dmu |
|
|