|
| 1 | +"""ACT-R Activation Equation — 4-component memory activation scoring. |
| 2 | +
|
| 3 | +Implements the classic ACT-R equation: A_i = B_i + S_i + P_i + ε_i |
| 4 | +
|
| 5 | +Components: |
| 6 | + B_i: Base-level learning (recency/frequency from access_timestamps) |
| 7 | + S_i: Spreading activation (cosine sim to context + L0/L1 embeddings) |
| 8 | + P_i: Partial matching penalty (metadata mismatches) |
| 9 | + ε_i: Logistic noise (s=0.4) |
| 10 | +
|
| 11 | +Parameters are human-calibrated starting points. Consolidation evolves them. |
| 12 | +Decades-validated cognitive science: d=0.5, s=0.4, P=-1.0, tau=0.0. |
| 13 | +""" |
| 14 | + |
| 15 | +import math |
| 16 | +import random |
| 17 | +import logging |
| 18 | +from datetime import datetime, timezone |
| 19 | +from typing import Any |
| 20 | + |
| 21 | +import numpy as np |
| 22 | + |
| 23 | +logger = logging.getLogger("agent.activation") |
| 24 | + |
| 25 | +# ACT-R default parameters (human-calibrated starting points) |
| 26 | +DEFAULT_DECAY_D = 0.5 # base-level decay rate |
| 27 | +DEFAULT_NOISE_S = 0.4 # logistic noise spread |
| 28 | +DEFAULT_MISMATCH_P = -1.0 # partial matching penalty scale |
| 29 | +DEFAULT_THRESHOLD_TAU = 0.0 # persist threshold |
| 30 | + |
| 31 | + |
| 32 | +def cosine_similarity(a: np.ndarray, b: np.ndarray) -> float: |
| 33 | + """Cosine similarity between two vectors.""" |
| 34 | + norm_a = np.linalg.norm(a) |
| 35 | + norm_b = np.linalg.norm(b) |
| 36 | + if norm_a == 0 or norm_b == 0: |
| 37 | + return 0.0 |
| 38 | + return float(np.dot(a, b) / (norm_a * norm_b)) |
| 39 | + |
| 40 | + |
| 41 | +def base_level_activation( |
| 42 | + access_timestamps: list[datetime], |
| 43 | + now: datetime | None = None, |
| 44 | + d: float = DEFAULT_DECAY_D, |
| 45 | +) -> float: |
| 46 | + """B_i = ln(sum(t_j^{-d})) where t_j is seconds since each access. |
| 47 | +
|
| 48 | + Uses access_timestamps array (TSM paper: dialogue time). |
| 49 | + Falls back to 0.0 if no access history. |
| 50 | + """ |
| 51 | + if not access_timestamps: |
| 52 | + return 0.0 |
| 53 | + |
| 54 | + if now is None: |
| 55 | + now = datetime.now(timezone.utc) |
| 56 | + |
| 57 | + total = 0.0 |
| 58 | + for ts in access_timestamps: |
| 59 | + # Ensure timezone-aware comparison |
| 60 | + if ts.tzinfo is None: |
| 61 | + ts = ts.replace(tzinfo=timezone.utc) |
| 62 | + age_seconds = max(1.0, (now - ts).total_seconds()) |
| 63 | + total += age_seconds ** (-d) |
| 64 | + |
| 65 | + if total <= 0: |
| 66 | + return 0.0 |
| 67 | + return math.log(total) |
| 68 | + |
| 69 | + |
| 70 | +def spreading_activation( |
| 71 | + memory_embedding: np.ndarray, |
| 72 | + attention_embedding: np.ndarray | None = None, |
| 73 | + layer_embeddings: list[tuple[str, float, np.ndarray]] | None = None, |
| 74 | + context_weight: float = 0.4, |
| 75 | + identity_weight: float = 0.6, |
| 76 | +) -> float: |
| 77 | + """S_i = weighted cosine similarity to context and identity/goals. |
| 78 | +
|
| 79 | + Args: |
| 80 | + memory_embedding: The memory's embedding vector. |
| 81 | + attention_embedding: Current attention focus embedding (from §3.10). |
| 82 | + layer_embeddings: (text, weight, vector) tuples from layers.get_all_layer_embeddings(). |
| 83 | + context_weight: Weight for attention context similarity. |
| 84 | + identity_weight: Weight for L0/L1 identity/goal similarity. |
| 85 | + """ |
| 86 | + score = 0.0 |
| 87 | + |
| 88 | + # Context relevance (attention embedding) |
| 89 | + if attention_embedding is not None: |
| 90 | + ctx_sim = cosine_similarity(memory_embedding, attention_embedding) |
| 91 | + score += context_weight * ctx_sim |
| 92 | + |
| 93 | + # Identity/goal relevance (L0/L1 embeddings) |
| 94 | + if layer_embeddings: |
| 95 | + weighted_sim = 0.0 |
| 96 | + total_weight = 0.0 |
| 97 | + for _text, weight, vec in layer_embeddings: |
| 98 | + sim = cosine_similarity(memory_embedding, vec) |
| 99 | + weighted_sim += weight * sim |
| 100 | + total_weight += weight |
| 101 | + if total_weight > 0: |
| 102 | + score += identity_weight * (weighted_sim / total_weight) |
| 103 | + |
| 104 | + return min(score, 1.0) |
| 105 | + |
| 106 | + |
| 107 | +def partial_matching_penalty( |
| 108 | + memory_metadata: dict[str, Any], |
| 109 | + query_metadata: dict[str, Any], |
| 110 | + p: float = DEFAULT_MISMATCH_P, |
| 111 | +) -> float: |
| 112 | + """P_i = P * sum(mismatch_k) — penalize metadata mismatches. |
| 113 | +
|
| 114 | + Checks type, source_tag, and tags overlap. |
| 115 | + """ |
| 116 | + mismatches = 0.0 |
| 117 | + |
| 118 | + # Type mismatch |
| 119 | + if query_metadata.get("type") and memory_metadata.get("type"): |
| 120 | + if query_metadata["type"] != memory_metadata["type"]: |
| 121 | + mismatches += 0.3 |
| 122 | + |
| 123 | + # Source mismatch |
| 124 | + if query_metadata.get("source_tag") and memory_metadata.get("source_tag"): |
| 125 | + if query_metadata["source_tag"] != memory_metadata["source_tag"]: |
| 126 | + mismatches += 0.2 |
| 127 | + |
| 128 | + # Tags overlap (less overlap = more penalty) |
| 129 | + q_tags = set(query_metadata.get("tags", [])) |
| 130 | + m_tags = set(memory_metadata.get("tags", [])) |
| 131 | + if q_tags and m_tags: |
| 132 | + overlap = len(q_tags & m_tags) / max(len(q_tags | m_tags), 1) |
| 133 | + mismatches += 0.5 * (1.0 - overlap) |
| 134 | + |
| 135 | + return p * mismatches |
| 136 | + |
| 137 | + |
| 138 | +def logistic_noise(s: float = DEFAULT_NOISE_S) -> float: |
| 139 | + """ε_i — ACT-R standard logistic noise. |
| 140 | +
|
| 141 | + Provides stochastic floor so the gate can surprise itself. |
| 142 | + """ |
| 143 | + p = random.random() |
| 144 | + p = max(0.001, min(0.999, p)) |
| 145 | + return s * math.log(p / (1.0 - p)) |
| 146 | + |
| 147 | + |
| 148 | +def compute_activation( |
| 149 | + memory_embedding: np.ndarray, |
| 150 | + access_timestamps: list[datetime], |
| 151 | + memory_metadata: dict[str, Any] | None = None, |
| 152 | + query_metadata: dict[str, Any] | None = None, |
| 153 | + attention_embedding: np.ndarray | None = None, |
| 154 | + layer_embeddings: list[tuple[str, float, np.ndarray]] | None = None, |
| 155 | + d: float = DEFAULT_DECAY_D, |
| 156 | + s: float = DEFAULT_NOISE_S, |
| 157 | + p: float = DEFAULT_MISMATCH_P, |
| 158 | + tau: float = DEFAULT_THRESHOLD_TAU, |
| 159 | +) -> tuple[float, dict[str, float]]: |
| 160 | + """Compute full ACT-R activation: A_i = B_i + S_i + P_i + ε_i |
| 161 | +
|
| 162 | + Returns (activation_score, component_breakdown). |
| 163 | + """ |
| 164 | + b_i = base_level_activation(access_timestamps, d=d) |
| 165 | + s_i = spreading_activation( |
| 166 | + memory_embedding, attention_embedding, layer_embeddings, |
| 167 | + ) |
| 168 | + p_i = partial_matching_penalty( |
| 169 | + memory_metadata or {}, query_metadata or {}, p=p, |
| 170 | + ) |
| 171 | + eps_i = logistic_noise(s) |
| 172 | + |
| 173 | + a_i = b_i + s_i + p_i + eps_i |
| 174 | + |
| 175 | + components = { |
| 176 | + "base_level": b_i, |
| 177 | + "spreading": s_i, |
| 178 | + "partial_match": p_i, |
| 179 | + "noise": eps_i, |
| 180 | + "total": a_i, |
| 181 | + "threshold": tau, |
| 182 | + "above_threshold": a_i > tau, |
| 183 | + } |
| 184 | + |
| 185 | + return a_i, components |
0 commit comments