superlocalmemory 3.2.1 → 3.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/CHANGELOG.md +23 -1
  2. package/README.md +61 -1
  3. package/package.json +1 -1
  4. package/pyproject.toml +26 -1
  5. package/src/superlocalmemory/attribution/signer.py +6 -1
  6. package/src/superlocalmemory/core/config.py +114 -1
  7. package/src/superlocalmemory/core/consolidation_engine.py +595 -0
  8. package/src/superlocalmemory/core/embeddings.py +0 -1
  9. package/src/superlocalmemory/core/engine.py +164 -674
  10. package/src/superlocalmemory/core/engine_wiring.py +474 -0
  11. package/src/superlocalmemory/core/graph_analyzer.py +199 -0
  12. package/src/superlocalmemory/core/recall_pipeline.py +247 -0
  13. package/src/superlocalmemory/core/store_pipeline.py +483 -0
  14. package/src/superlocalmemory/core/worker_pool.py +35 -12
  15. package/src/superlocalmemory/encoding/auto_linker.py +308 -0
  16. package/src/superlocalmemory/encoding/context_generator.py +175 -0
  17. package/src/superlocalmemory/encoding/temporal_validator.py +513 -0
  18. package/src/superlocalmemory/hooks/auto_invoker.py +484 -0
  19. package/src/superlocalmemory/retrieval/channel_registry.py +154 -0
  20. package/src/superlocalmemory/retrieval/engine.py +12 -0
  21. package/src/superlocalmemory/retrieval/semantic_channel.py +87 -3
  22. package/src/superlocalmemory/retrieval/spreading_activation.py +311 -0
  23. package/src/superlocalmemory/retrieval/strategy.py +6 -6
  24. package/src/superlocalmemory/retrieval/vector_store.py +386 -0
  25. package/src/superlocalmemory/server/routes/v3_api.py +576 -0
  26. package/src/superlocalmemory/storage/access_log.py +169 -0
  27. package/src/superlocalmemory/storage/database.py +288 -0
  28. package/src/superlocalmemory/storage/schema.py +10 -0
  29. package/src/superlocalmemory/storage/schema_v32.py +252 -0
  30. package/src/superlocalmemory/storage/v2_migrator.py +24 -2
@@ -20,7 +20,7 @@ from __future__ import annotations
20
20
 
21
21
  import logging
22
22
  import math
23
- from typing import TYPE_CHECKING
23
+ from typing import TYPE_CHECKING, Any
24
24
 
25
25
  import numpy as np
26
26
 
@@ -71,6 +71,8 @@ class SemanticChannel:
71
71
  fresh facts (low access_count) use cosine, frequently-accessed facts
72
72
  transition to Fisher-Rao distance for uncertainty-aware similarity.
73
73
 
74
+ V3.2: VectorStore KNN fast path when available, falls back to full scan.
75
+
74
76
  Graduated ramp: weight = min(1.2, access_count / 10 * 1.2)
75
77
  Final sim = fisher_weight * fisher_sim + (1 - fisher_weight) * cosine_sim
76
78
  """
@@ -81,6 +83,7 @@ class SemanticChannel:
81
83
  fisher_temperature: float = 15.0,
82
84
  embedder: object | None = None,
83
85
  fisher_mode: str = "simplified",
86
+ vector_store: Any | None = None,
84
87
  ) -> None:
85
88
  self._db = db
86
89
  self._temperature = fisher_temperature
@@ -88,6 +91,7 @@ class SemanticChannel:
88
91
  self._fisher_mode = fisher_mode if fisher_mode in ("simplified", "full") else "simplified"
89
92
  # Lazily instantiated full metric (avoids import cost when not needed)
90
93
  self._full_metric: object | None = None
94
+ self._vector_store = vector_store
91
95
 
92
96
  def search(
93
97
  self,
@@ -97,8 +101,8 @@ class SemanticChannel:
97
101
  ) -> list[tuple[str, float]]:
98
102
  """Search for semantically similar facts.
99
103
 
100
- Uses graduated Fisher-Rao ramp: access_count < 1 = pure cosine,
101
- access_count >= 10 = full Fisher-Rao (1.2x weight).
104
+ Uses VectorStore KNN if available, otherwise full-table scan.
105
+ Fisher-Rao scoring preserved as post-KNN secondary signal.
102
106
 
103
107
  Args:
104
108
  query_embedding: Dense vector for the query.
@@ -114,6 +118,86 @@ class SemanticChannel:
114
118
 
115
119
  q_vec = np.array(query_embedding, dtype=np.float32)
116
120
 
121
+ # --- FAST PATH: sqlite-vec KNN ---
122
+ if self._vector_store and self._vector_store.available:
123
+ results = self._search_via_vector_store(
124
+ query_embedding, q_vec, profile_id, top_k,
125
+ )
126
+ if results: # If vec0 returned results, use them
127
+ return results
128
+ # If vec0 is empty (cold start), fall through to full scan
129
+
130
+ # --- FALLBACK: full-table scan (original code, unchanged) ---
131
+ return self._search_full_scan(query_embedding, q_vec, profile_id, top_k)
132
+
133
+ def _search_via_vector_store(
134
+ self,
135
+ query_embedding: list[float],
136
+ q_vec: np.ndarray,
137
+ profile_id: str,
138
+ top_k: int,
139
+ ) -> list[tuple[str, float]]:
140
+ """KNN via VectorStore, then Fisher-Rao re-scoring on top-K subset."""
141
+ # Step 1: Fast KNN -- get 2x top_k candidates for Fisher re-ranking
142
+ knn_results = self._vector_store.search(
143
+ query_embedding, top_k=top_k * 2, profile_id=profile_id,
144
+ )
145
+ if not knn_results:
146
+ return [] # Caller falls through to full scan
147
+
148
+ # Step 2: Load only the candidate facts (NOT all facts)
149
+ candidate_ids = [fid for fid, _ in knn_results]
150
+ knn_scores = {fid: score for fid, score in knn_results}
151
+ facts = self._db.get_facts_by_ids(candidate_ids, profile_id)
152
+
153
+ if not facts:
154
+ return [(fid, score) for fid, score in knn_results[:top_k]]
155
+
156
+ # Step 3: Fisher-Rao re-scoring on the subset
157
+ q_mean: np.ndarray | None = None
158
+ q_var: np.ndarray | None = None
159
+ if self._embedder and hasattr(self._embedder, 'compute_fisher_params'):
160
+ qm, qv = self._embedder.compute_fisher_params(query_embedding)
161
+ q_mean = np.array(qm, dtype=np.float32)
162
+ q_var = np.array(qv, dtype=np.float32)
163
+
164
+ scored: list[tuple[str, float]] = []
165
+ for fact in facts:
166
+ cos_sim = knn_scores.get(fact.fact_id, 0.0)
167
+
168
+ # Graduated Fisher-Rao ramp (preserved from original)
169
+ fisher_weight = min(1.2, (fact.access_count or 0) / 10.0 * 1.2)
170
+
171
+ if (fisher_weight > 0.01
172
+ and fact.fisher_variance is not None
173
+ and fact.embedding is not None
174
+ and len(fact.fisher_variance) == len(q_vec)):
175
+ f_vec = np.array(fact.embedding, dtype=np.float32)
176
+ var_vec = np.array(fact.fisher_variance, dtype=np.float32)
177
+ f_sim = self._compute_fisher_sim(
178
+ q_vec, f_vec, var_vec, fact, q_mean, q_var,
179
+ )
180
+ capped_w = min(1.0, fisher_weight)
181
+ sim = capped_w * f_sim + (1.0 - capped_w) * cos_sim
182
+ else:
183
+ sim = cos_sim
184
+
185
+ if sim > 0.3:
186
+ scored.append((fact.fact_id, sim))
187
+
188
+ scored.sort(key=lambda x: x[1], reverse=True)
189
+ return scored[:top_k]
190
+
191
+ def _search_full_scan(
192
+ self,
193
+ query_embedding: list[float],
194
+ q_vec: np.ndarray,
195
+ profile_id: str,
196
+ top_k: int,
197
+ ) -> list[tuple[str, float]]:
198
+ """Original full-table-scan search. Used as fallback when VectorStore
199
+ is unavailable or empty (cold start).
200
+ """
117
201
  # Compute query Fisher params for Bayesian comparison (F45 fix)
118
202
  q_mean: np.ndarray | None = None
119
203
  q_var: np.ndarray | None = None
@@ -0,0 +1,311 @@
1
+ # Copyright (c) 2026 Varun Pratap Bhardwaj / Qualixar
2
+ # Licensed under the MIT License - see LICENSE file
3
+ # Part of SuperLocalMemory V3
4
+
5
+ """SYNAPSE spreading activation -- 5th retrieval channel.
6
+
7
+ SYNAPSE (arXiv 2601.02744) 5-step algorithm adapted for SLM.
8
+ Pure math -- no LLM calls at query time. With M=7, T=3 the
9
+ computation is ~21 neighbor lookups (<5ms on SQLite with indexes).
10
+
11
+ Reads BOTH graph_edges + association_edges via UNION query (Rule 13).
12
+ Registered as 5th channel via ChannelRegistry (needs_embedding=True).
13
+
14
+ Part of Qualixar | Author: Varun Pratap Bhardwaj
15
+ License: MIT
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import hashlib
21
+ import logging
22
+ import math
23
+ from dataclasses import dataclass
24
+ from typing import Any
25
+
26
+ import numpy as np
27
+
28
+ from superlocalmemory.storage.models import _new_id
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+
33
+ # ---------------------------------------------------------------------------
34
+ # Configuration (frozen dataclass, Rule 10)
35
+ # ---------------------------------------------------------------------------
36
+
37
+ @dataclass(frozen=True)
38
+ class SpreadingActivationConfig:
39
+ """Configuration for SYNAPSE spreading activation.
40
+
41
+ All hyperparameters from the SYNAPSE paper (arXiv 2601.02744).
42
+ SYNAPSE tuned on 384d (all-MiniLM-L6-v2). SLM uses 768d
43
+ (nomic-embed-text). Phase 3 calibration test verifies convergence.
44
+ """
45
+
46
+ alpha: float = 1.0 # Seed scaling factor
47
+ delta: float = 0.5 # Node retention / self-decay per iteration
48
+ spreading_factor: float = 0.8 # S: energy diffusion rate
49
+ theta: float = 0.5 # Activation threshold for sigmoid
50
+ top_m: int = 7 # Lateral inhibition: max active nodes
51
+ max_iterations: int = 3 # T: propagation depth
52
+ tau_gate: float = 0.12 # FOK confidence gate
53
+ enabled: bool = False # Feature flag (Rule 12)
54
+
55
+
56
+ # ---------------------------------------------------------------------------
57
+ # SpreadingActivation Channel
58
+ # ---------------------------------------------------------------------------
59
+
60
+ class SpreadingActivation:
61
+ """SYNAPSE 5-step spreading activation as 5th retrieval channel.
62
+
63
+ Algorithm:
64
+ Step 1: Initialization with ALPHA seed scaling
65
+ Step 2: Propagation with fan effect (out-degree normalization)
66
+ Step 3: Lateral inhibition (top-M=7 pruning)
67
+ Step 4: Nonlinear sigmoid gating
68
+ Step 5: Iterate T=3 times, then FOK gate
69
+
70
+ Registered as 5th channel via ChannelRegistry (Rule 07).
71
+ Reads BOTH graph_edges + association_edges via UNION query (Rule 13).
72
+ """
73
+
74
+ def __init__(
75
+ self,
76
+ db: Any,
77
+ vector_store: Any,
78
+ config: SpreadingActivationConfig | None = None,
79
+ ) -> None:
80
+ self._db = db
81
+ self._vector_store = vector_store
82
+ self._config = config or SpreadingActivationConfig()
83
+
84
+ def search(
85
+ self,
86
+ query: Any,
87
+ profile_id: str = "",
88
+ top_k: int = 7,
89
+ ) -> list[tuple[str, float]]:
90
+ """Channel-compatible interface: (query, top_k) -> [(fact_id, score)].
91
+
92
+ Matches ANNSearchable protocol (Rule 07).
93
+ """
94
+ if not self._config.enabled:
95
+ return []
96
+
97
+ try:
98
+ # Step 0: Get seed nodes from VectorStore KNN
99
+ seed_results = self._vector_store.search(
100
+ query, top_k=self._config.top_m,
101
+ )
102
+ if not seed_results:
103
+ return []
104
+
105
+ # Check cache first
106
+ query_hash = self._compute_query_hash(query, profile_id)
107
+ cached = self._get_cached_results(query_hash, profile_id)
108
+ if cached:
109
+ return cached[:top_k]
110
+
111
+ # Run 5-step spreading activation
112
+ activations = self._propagate(seed_results, profile_id)
113
+
114
+ # FOK gating
115
+ if not self._fok_check(activations):
116
+ return []
117
+
118
+ # Cache results
119
+ self._cache_results(query_hash, profile_id, activations)
120
+
121
+ # Return top-K sorted by activation
122
+ results = sorted(
123
+ activations.items(), key=lambda x: x[1], reverse=True,
124
+ )
125
+ return results[:top_k]
126
+
127
+ except Exception as exc:
128
+ logger.debug(
129
+ "SpreadingActivation.search failed for profile %s: %s",
130
+ profile_id, exc,
131
+ )
132
+ return []
133
+
134
+ def _propagate(
135
+ self,
136
+ seeds: list[tuple[str, float]],
137
+ profile_id: str,
138
+ ) -> dict[str, float]:
139
+ """Execute the 5-step SYNAPSE algorithm.
140
+
141
+ Step 1: a_i^(0) = alpha * sim(h_i, h_q) for seeds, 0 otherwise
142
+ Step 2: u_i^(t+1) = delta * a_i^(t) + S * SUM(w_ji/deg(j) * a_j^(t))
143
+ Step 3: Lateral inhibition -- keep top-M=7 only
144
+ Step 4: sigmoid(u - theta)
145
+ Step 5: Iterate T=3 times
146
+ """
147
+ cfg = self._config
148
+
149
+ # Step 1: Initialization
150
+ activations: dict[str, float] = {}
151
+ for fact_id, similarity in seeds:
152
+ activations[fact_id] = cfg.alpha * similarity
153
+
154
+ # Precompute out-degrees for fan effect
155
+ degree_cache: dict[str, int] = {}
156
+
157
+ # Steps 2-4, repeated T times
158
+ for _iteration in range(cfg.max_iterations):
159
+ new_activations: dict[str, float] = {}
160
+
161
+ for node_id, activation in activations.items():
162
+ if activation < 0.001:
163
+ continue
164
+
165
+ # Get neighbors from BOTH tables (Rule 13)
166
+ neighbors = self._get_unified_neighbors(node_id, profile_id)
167
+
168
+ # Out-degree for fan effect normalization
169
+ if node_id not in degree_cache:
170
+ degree_cache[node_id] = max(len(neighbors), 1)
171
+ out_degree = degree_cache[node_id]
172
+
173
+ # Step 2: Propagation with fan effect
174
+ for neighbor_id, edge_weight in neighbors:
175
+ spread = (
176
+ cfg.spreading_factor
177
+ * (edge_weight / out_degree)
178
+ * activation
179
+ )
180
+ new_activations[neighbor_id] = (
181
+ new_activations.get(neighbor_id, 0.0) + spread
182
+ )
183
+
184
+ # Add self-retention (delta * current activation)
185
+ for node_id, activation in activations.items():
186
+ new_activations[node_id] = (
187
+ new_activations.get(node_id, 0.0) + cfg.delta * activation
188
+ )
189
+
190
+ # Step 3: Lateral inhibition -- keep only top-M
191
+ sorted_nodes = sorted(
192
+ new_activations.items(), key=lambda x: x[1], reverse=True,
193
+ )
194
+ top_m_nodes = sorted_nodes[: cfg.top_m]
195
+
196
+ # Step 4: Nonlinear activation (sigmoid with threshold shift)
197
+ activations = {}
198
+ for node_id, raw_activation in top_m_nodes:
199
+ gated = 1.0 / (1.0 + math.exp(-(raw_activation - cfg.theta)))
200
+ activations[node_id] = gated
201
+
202
+ return activations
203
+
204
+ def _get_unified_neighbors(
205
+ self, node_id: str, profile_id: str,
206
+ ) -> list[tuple[str, float]]:
207
+ """Get neighbors from BOTH graph_edges and association_edges.
208
+
209
+ Uses bidirectional UNION query (Section 4 of LLD).
210
+ """
211
+ try:
212
+ rows = self._db.execute(
213
+ """
214
+ SELECT target_id AS neighbor_id, weight FROM graph_edges
215
+ WHERE source_id = ? AND profile_id = ?
216
+ UNION ALL
217
+ SELECT target_fact_id AS neighbor_id, weight FROM association_edges
218
+ WHERE source_fact_id = ? AND profile_id = ?
219
+ UNION ALL
220
+ SELECT source_id AS neighbor_id, weight FROM graph_edges
221
+ WHERE target_id = ? AND profile_id = ?
222
+ UNION ALL
223
+ SELECT source_fact_id AS neighbor_id, weight FROM association_edges
224
+ WHERE target_fact_id = ? AND profile_id = ?
225
+ """,
226
+ (node_id, profile_id, node_id, profile_id,
227
+ node_id, profile_id, node_id, profile_id),
228
+ )
229
+ return [
230
+ (dict(r)["neighbor_id"], dict(r)["weight"]) for r in rows
231
+ ]
232
+ except Exception as exc:
233
+ logger.debug(
234
+ "SpreadingActivation: UNION query failed for node %s "
235
+ "profile %s: %s",
236
+ node_id, profile_id, exc,
237
+ )
238
+ return []
239
+
240
+ def _fok_check(self, activations: dict[str, float]) -> bool:
241
+ """Feeling-of-Knowing gate.
242
+
243
+ If max activation < tau_gate (0.12), reject results as noise.
244
+ """
245
+ if not activations:
246
+ return False
247
+ return max(activations.values()) >= self._config.tau_gate
248
+
249
+ def _compute_query_hash(self, query: Any, profile_id: str) -> str:
250
+ """Deterministic hash for cache key."""
251
+ if isinstance(query, np.ndarray):
252
+ data = query.tobytes() + profile_id.encode()
253
+ elif isinstance(query, list):
254
+ data = np.array(query, dtype=np.float32).tobytes() + profile_id.encode()
255
+ else:
256
+ data = str(query).encode() + profile_id.encode()
257
+ return hashlib.sha256(data).hexdigest()[:16]
258
+
259
+ def _get_cached_results(
260
+ self, query_hash: str, profile_id: str,
261
+ ) -> list[tuple[str, float]] | None:
262
+ """Check activation_cache for recent results."""
263
+ try:
264
+ rows = self._db.execute(
265
+ "SELECT node_id, activation_value FROM activation_cache "
266
+ "WHERE profile_id = ? AND query_hash = ? "
267
+ "AND expires_at > datetime('now') "
268
+ "ORDER BY activation_value DESC",
269
+ (profile_id, query_hash),
270
+ )
271
+ if not rows:
272
+ return None
273
+ return [
274
+ (dict(r)["node_id"], dict(r)["activation_value"])
275
+ for r in rows
276
+ ]
277
+ except Exception:
278
+ return None
279
+
280
+ def _cache_results(
281
+ self,
282
+ query_hash: str,
283
+ profile_id: str,
284
+ activations: dict[str, float],
285
+ ) -> None:
286
+ """Store results in activation_cache with 1-hour TTL."""
287
+ try:
288
+ for node_id, value in activations.items():
289
+ self._db.execute(
290
+ "INSERT OR REPLACE INTO activation_cache "
291
+ "(cache_id, profile_id, query_hash, node_id, "
292
+ " activation_value, iteration, created_at, expires_at) "
293
+ "VALUES (?, ?, ?, ?, ?, ?, datetime('now'), "
294
+ "datetime('now', '+1 hour'))",
295
+ (_new_id(), profile_id, query_hash, node_id, value,
296
+ self._config.max_iterations),
297
+ )
298
+ except Exception as exc:
299
+ logger.debug("Cache write failed: %s", exc)
300
+
301
+ def cleanup_expired_cache(self) -> int:
302
+ """Delete expired cache entries. Called by maintenance."""
303
+ try:
304
+ result = self._db.execute(
305
+ "DELETE FROM activation_cache "
306
+ "WHERE expires_at < datetime('now')",
307
+ (),
308
+ )
309
+ return len(result) if result else 0
310
+ except Exception:
311
+ return 0
@@ -16,12 +16,12 @@ import re
16
16
  from dataclasses import dataclass, field
17
17
 
18
18
  STRATEGY_PRESETS: dict[str, dict[str, float]] = {
19
- "temporal": {"semantic": 0.8, "bm25": 1.5, "entity_graph": 0.8, "temporal": 2.0},
20
- "multi_hop": {"semantic": 1.0, "bm25": 0.8, "entity_graph": 2.0, "temporal": 0.5},
21
- "aggregation": {"semantic": 1.2, "bm25": 1.5, "entity_graph": 1.0, "temporal": 0.5},
22
- "opinion": {"semantic": 1.8, "bm25": 0.6, "entity_graph": 0.8, "temporal": 0.3},
23
- "factual": {"semantic": 1.2, "bm25": 1.4, "entity_graph": 1.0, "temporal": 0.6},
24
- "entity": {"semantic": 1.0, "bm25": 1.5, "entity_graph": 1.2, "temporal": 0.5},
19
+ "temporal": {"semantic": 0.8, "bm25": 1.5, "entity_graph": 0.8, "temporal": 2.0, "spreading_activation": 0.5},
20
+ "multi_hop": {"semantic": 1.0, "bm25": 0.8, "entity_graph": 2.0, "temporal": 0.5, "spreading_activation": 2.0},
21
+ "aggregation": {"semantic": 1.2, "bm25": 1.5, "entity_graph": 1.0, "temporal": 0.5, "spreading_activation": 0.8},
22
+ "opinion": {"semantic": 1.8, "bm25": 0.6, "entity_graph": 0.8, "temporal": 0.3, "spreading_activation": 0.5},
23
+ "factual": {"semantic": 1.2, "bm25": 1.4, "entity_graph": 1.0, "temporal": 0.6, "spreading_activation": 0.8},
24
+ "entity": {"semantic": 1.0, "bm25": 1.5, "entity_graph": 1.2, "temporal": 0.5, "spreading_activation": 1.0},
25
25
  "general": {},
26
26
  }
27
27