superlocalmemory 3.4.58 → 3.4.59

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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,66 @@ All notable changes to SuperLocalMemory V3 will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [3.4.59] - 2026-05-31 — Graph Edge Cap + Recall Reliability
9
+
10
+ **Fixes SLM falling into degraded FTS5 mode on every session start**, and stops
11
+ the knowledge graph from growing into an O(n²) edge explosion that made recall
12
+ slow as the fact corpus scaled beyond 10K facts.
13
+
14
+ ### Root Causes Fixed
15
+
16
+ **1. MCP timeout too aggressive for dense graphs (degraded mode bug)**
17
+ The v3.4.57 timeout reduction (60s→8s) was correct for small graphs but
18
+ backfired at scale. With a 17K-fact corpus and 2.1M graph edges, full 6-channel
19
+ recall (including spreading activation + Hopfield) takes 13–15s. The 8s timeout
20
+ always expired → `pool_recall` raised `PoolError` → session_init fell back to
21
+ emergency FTS5 BM25. Every session started in degraded mode silently.
22
+
23
+ **Fix:** `DaemonPoolProxy.timeout_s` raised from 8s to 30s. Covers the current
24
+ worst-case (13.4s full recall) with 2× headroom. The orphan-flood concern from
25
+ v3.4.57 is mitigated by the ingest cap reductions below, which stop the graph
26
+ from growing further.
27
+
28
+ **2. Knowledge graph edge explosion (O(n²) growth)**
29
+ At 17K facts, the corpus hit 2.1M graph edges (avg 121 per node). Root cause:
30
+ `_MAX_ENTITY_EDGES_PER_ENTITY = 20` was designed for ~500-fact graphs. At scale,
31
+ hub entities (appearing in 1000s of facts) accumulated 5000+ edges per node.
32
+ Spreading activation must fan out across all edges per node, causing 9s SA time.
33
+
34
+ **Fixes:**
35
+ - `_MAX_ENTITY_EDGES_PER_ENTITY` lowered from 20 → 5
36
+ - `_MAX_CAUSAL_EDGES_PER_ENTITY` lowered from 20 → 5
37
+ - **Hub node filter added:** nodes already at ≥ 200 total edges are skipped
38
+ during ingest. High-frequency hub nodes (e.g. a term appearing in every fact)
39
+ link everything to everything — they are graph noise, not graph signal.
40
+ - Hub cache shared across entity/causal edge builders per `build_edges` call to
41
+ avoid redundant DB queries.
42
+
43
+ **3. Spreading activation UNION query not using indexes (SA slow path)**
44
+ The `_get_unified_neighbors` UNION ALL query fetched all edges for a node then
45
+ sorted them, preventing the `idx_edges_source_weight` and `idx_edges_target_weight`
46
+ covering indexes from terminating early. Fix: push `ORDER BY weight DESC LIMIT ?`
47
+ inside each UNION branch (wrapped in `SELECT * FROM (...)` per SQLite compound
48
+ SELECT syntax). SQLite now stops after `max_neighbors_per_node` rows per branch
49
+ using the covering index instead of materialising the full edge set.
50
+
51
+ **4. Degree-cap pruner added to graph_pruner.py**
52
+ New `_cap_node_degree()` function using `ROW_NUMBER() OVER (PARTITION BY source_id
53
+ ORDER BY weight DESC)` — single-pass window function, no Python loops. Integrated
54
+ into `prune_graph()` as `cap_degree=True` (default). Automatically runs during
55
+ scheduled maintenance cycles to keep hub nodes bounded.
56
+
57
+ ### Changed
58
+ - `mcp/_daemon_proxy.py`: `timeout_s` default 8.0 → 30.0
59
+ - `encoding/graph_builder.py`: entity + causal caps 20 → 5; hub filter at 200 edges
60
+ - `core/graph_pruner.py`: `_cap_node_degree()` added; `prune_graph()` gains `cap_degree` param
61
+ - `retrieval/spreading_activation.py`: UNION LIMIT pushed inside each branch
62
+
63
+ ### Tests
64
+ 4053 passed, 15 skipped — no regressions.
65
+
66
+ ---
67
+
8
68
  ## [3.4.58] - 2026-05-30 — Permanent OpenMP SIGSEGV Fix
9
69
 
10
70
  **Eliminates the recurring Python crash popup on macOS Apple Silicon.** Any user
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "superlocalmemory",
3
- "version": "3.4.58",
3
+ "version": "3.4.59",
4
4
  "description": "Information-geometric agent memory with mathematical guarantees. 4-channel retrieval, Fisher-Rao similarity, zero-LLM mode, EU AI Act compliant. Works with Claude, Cursor, Windsurf, and 17+ AI tools.",
5
5
  "keywords": [
6
6
  "ai-memory",
package/pyproject.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "superlocalmemory"
3
- version = "3.4.58"
3
+ version = "3.4.59"
4
4
  description = "Information-geometric agent memory with mathematical guarantees"
5
5
  readme = "README.md"
6
6
  license = {text = "AGPL-3.0-or-later"}
@@ -28,7 +28,7 @@ if "OMP_NUM_THREADS" not in os.environ:
28
28
  os.environ["OMP_NUM_THREADS"] = "2"
29
29
  # ---------------------------------------------------------------------------
30
30
 
31
- __version__ = "3.4.58"
31
+ __version__ = "3.4.59"
32
32
 
33
33
  _REQUIRED_VERSIONS = {
34
34
  "sentence_transformers": "5.3.0",
@@ -28,12 +28,18 @@ from pathlib import Path
28
28
  logger = logging.getLogger("superlocalmemory.graph_pruner")
29
29
 
30
30
  _CHAIN_BATCH_LIMIT = 10_000
31
+ # v3.4.59: Nodes with more than this many total edges (in+out) are hub nodes.
32
+ # SA and entity_graph channels cap fan-out at 30/node, so edges beyond this
33
+ # threshold provide zero additional recall signal while bloating the graph.
34
+ _MAX_DEGREE_PER_NODE: int = 100
35
+ _HUB_PRUNE_BATCH: int = 500 # delete edges in batches to avoid giant IN clauses
31
36
 
32
37
 
33
38
  def prune_graph(
34
39
  db_path: str | Path,
35
40
  profile_id: str = "default",
36
41
  dry_run: bool = False,
42
+ cap_degree: bool = True,
37
43
  ) -> dict:
38
44
  """Run all graph pruning strategies for a specific profile.
39
45
 
@@ -41,7 +47,7 @@ def prune_graph(
41
47
  """
42
48
  conn = sqlite3.connect(str(db_path))
43
49
  conn.execute("PRAGMA journal_mode=WAL")
44
- conn.execute("PRAGMA busy_timeout=10000")
50
+ conn.execute("PRAGMA busy_timeout=30000")
45
51
  conn.row_factory = sqlite3.Row
46
52
 
47
53
  stats = {
@@ -49,6 +55,7 @@ def prune_graph(
49
55
  "supersedes_collapsed": 0,
50
56
  "self_loops_removed": 0,
51
57
  "duplicates_removed": 0,
58
+ "hub_edges_removed": 0,
52
59
  "total_before": 0,
53
60
  "total_after": 0,
54
61
  }
@@ -72,6 +79,10 @@ def prune_graph(
72
79
  stats["supersedes_collapsed"] = _collapse_supersedes_chains(
73
80
  c, profile_id, dry_run,
74
81
  )
82
+ if cap_degree:
83
+ stats["hub_edges_removed"] = _cap_node_degree(
84
+ c, profile_id, _MAX_DEGREE_PER_NODE, dry_run,
85
+ )
75
86
 
76
87
  if dry_run:
77
88
  c.execute("ROLLBACK")
@@ -91,10 +102,11 @@ def prune_graph(
91
102
  prefix = "(dry-run) " if dry_run else ""
92
103
  logger.info(
93
104
  "%sGraph pruning: removed %d edges (%.1f%%) in %.1fs — "
94
- "orphans=%d, supersedes=%d, self_loops=%d, duplicates=%d",
105
+ "orphans=%d, supersedes=%d, self_loops=%d, duplicates=%d, hub_cap=%d",
95
106
  prefix, total_removed, pct, elapsed,
96
107
  stats["orphans_removed"], stats["supersedes_collapsed"],
97
108
  stats["self_loops_removed"], stats["duplicates_removed"],
109
+ stats["hub_edges_removed"],
98
110
  )
99
111
 
100
112
  except Exception as exc:
@@ -288,3 +300,81 @@ def _collapse_supersedes_chains(
288
300
  )
289
301
 
290
302
  return len(delete_ids)
303
+
304
+
305
+ def _cap_node_degree(
306
+ c: sqlite3.Cursor,
307
+ profile_id: str,
308
+ max_degree: int,
309
+ dry_run: bool,
310
+ ) -> int:
311
+ """Remove low-weight edges from hub nodes (nodes with degree > max_degree).
312
+
313
+ v3.4.59: At 17K facts, popular entities (SLM, Claude, AgentAssert) caused
314
+ 1.4M+ entity edges — avg 121 per node, some nodes with 5000+. SA fan-out
315
+ is capped at 30/node during traversal, so edges beyond max_degree add zero
316
+ recall signal while making every graph query scan millions of rows.
317
+
318
+ Algorithm (single-pass window function — no Python loops):
319
+ 1. ROW_NUMBER() OVER (PARTITION BY source_id ORDER BY weight DESC) ranks
320
+ every edge per node in one full table scan.
321
+ 2. Edges with rn > max_degree are deleted in a single DELETE statement.
322
+ Requires SQLite 3.25+ (window functions). System is on 3.53.1.
323
+ """
324
+ if dry_run:
325
+ c.execute(
326
+ """
327
+ SELECT COUNT(*) as cnt FROM (
328
+ SELECT edge_id,
329
+ ROW_NUMBER() OVER (
330
+ PARTITION BY source_id ORDER BY weight DESC
331
+ ) as rn
332
+ FROM graph_edges
333
+ WHERE profile_id = ?
334
+ ) WHERE rn > ?
335
+ """,
336
+ (profile_id, max_degree),
337
+ )
338
+ excess = c.fetchone()["cnt"]
339
+ logger.info(
340
+ "(dry-run) _cap_node_degree: ~%d edges would be removed (max_degree=%d)",
341
+ excess, max_degree,
342
+ )
343
+ return excess
344
+
345
+ # Step 1: build temp keep-list in one pass (ROW_NUMBER ranks by weight DESC)
346
+ c.execute("CREATE TEMP TABLE IF NOT EXISTS _slm_keep_edges (edge_id TEXT PRIMARY KEY)")
347
+ c.execute("DELETE FROM _slm_keep_edges") # idempotent if called twice
348
+ c.execute(
349
+ """
350
+ INSERT INTO _slm_keep_edges (edge_id)
351
+ SELECT edge_id FROM (
352
+ SELECT edge_id,
353
+ ROW_NUMBER() OVER (
354
+ PARTITION BY source_id ORDER BY weight DESC
355
+ ) as rn
356
+ FROM graph_edges
357
+ WHERE profile_id = ?
358
+ ) WHERE rn <= ?
359
+ """,
360
+ (profile_id, max_degree),
361
+ )
362
+
363
+ # Step 2: delete everything not in keep-list (single DELETE)
364
+ c.execute(
365
+ """
366
+ DELETE FROM graph_edges
367
+ WHERE profile_id = ?
368
+ AND edge_id NOT IN (SELECT edge_id FROM _slm_keep_edges)
369
+ """,
370
+ (profile_id,),
371
+ )
372
+ deleted = c.rowcount
373
+
374
+ c.execute("DROP TABLE IF EXISTS _slm_keep_edges")
375
+
376
+ logger.info(
377
+ "_cap_node_degree: deleted %d low-weight edges (max_degree=%d)",
378
+ deleted, max_degree,
379
+ )
380
+ return deleted
@@ -76,11 +76,12 @@ class GraphBuilder:
76
76
 
77
77
  def build_edges(self, new_fact: AtomicFact, profile_id: str) -> list[GraphEdge]:
78
78
  """Create ALL relevant edges for *new_fact*. Persists and returns them."""
79
+ hub_cache: dict[str, int] = {} # shared across edge types to avoid redundant queries
79
80
  edges: list[GraphEdge] = []
80
- edges.extend(self._build_entity_edges(new_fact, profile_id))
81
+ edges.extend(self._build_entity_edges(new_fact, profile_id, hub_cache))
81
82
  edges.extend(self._build_temporal_edges(new_fact, profile_id))
82
83
  edges.extend(self._build_semantic_edges(new_fact, profile_id))
83
- edges.extend(self._build_causal_edges(new_fact, profile_id))
84
+ edges.extend(self._build_causal_edges(new_fact, profile_id, hub_cache))
84
85
 
85
86
  for edge in edges:
86
87
  self._db.store_edge(edge)
@@ -142,17 +143,33 @@ class GraphBuilder:
142
143
 
143
144
  # -- Edge builders (private) -------------------------------------------
144
145
 
145
- # V3.3.12: Cap entity edges per entity to prevent O(n²) explosion.
146
- # With 500+ facts sharing a popular entity, creating an edge to each
147
- # produced 44K+ edges and 22-min ingestion. Cap to 20 most recent per entity.
148
- _MAX_ENTITY_EDGES_PER_ENTITY: int = 20
146
+ # v3.4.59: Lowered from 20→5. At 17K facts, 20 edges × many entities per fact
147
+ # produces 1.4M+ entity edges and 13s recalls. 5 is sufficient graph signal.
148
+ _MAX_ENTITY_EDGES_PER_ENTITY: int = 5
149
+ # v3.4.59: Hub filter — nodes with > 200 total edges are "gravity wells"
150
+ # (e.g. every fact mentions "SLM"). Skip adding more edges to them.
151
+ _MAX_HUB_DEGREE: int = 200
152
+
153
+ def _node_degree(self, fact_id: str, profile_id: str, cache: dict[str, int]) -> int:
154
+ """Cached total degree (in + out) for a node."""
155
+ if fact_id not in cache:
156
+ rows = self._db.execute(
157
+ "SELECT COUNT(*) as cnt FROM graph_edges "
158
+ "WHERE profile_id = ? AND (source_id = ? OR target_id = ?)",
159
+ (profile_id, fact_id, fact_id),
160
+ )
161
+ cache[fact_id] = int(dict(rows[0])["cnt"]) if rows else 0
162
+ return cache[fact_id]
149
163
 
150
164
  def _build_entity_edges(
151
165
  self, new_fact: AtomicFact, profile_id: str,
166
+ hub_cache: dict[str, int] | None = None,
152
167
  ) -> list[GraphEdge]:
153
168
  """ENTITY edges: shared canonical entity — capped to most recent per entity."""
154
169
  if not new_fact.canonical_entities:
155
170
  return []
171
+ if hub_cache is None:
172
+ hub_cache = {}
156
173
  edges: list[GraphEdge] = []
157
174
  seen: set[str] = set()
158
175
 
@@ -163,6 +180,8 @@ class GraphBuilder:
163
180
  break
164
181
  if other.fact_id == new_fact.fact_id or other.fact_id in seen:
165
182
  continue
183
+ if self._node_degree(other.fact_id, profile_id, hub_cache) >= self._MAX_HUB_DEGREE:
184
+ continue # skip hub nodes — they link everything to everything
166
185
  if self._edge_exists(new_fact.fact_id, other.fact_id, EdgeType.ENTITY, profile_id):
167
186
  continue
168
187
  seen.add(other.fact_id)
@@ -261,17 +280,20 @@ class GraphBuilder:
261
280
  break
262
281
  return edges
263
282
 
264
- # V3.3.13: Cap causal edges per entity to prevent O(n²) explosion (same as entity/temporal).
265
- _MAX_CAUSAL_EDGES_PER_ENTITY: int = 20
283
+ # v3.4.59: Lowered from 20→5, same reasoning as entity cap.
284
+ _MAX_CAUSAL_EDGES_PER_ENTITY: int = 5
266
285
 
267
286
  def _build_causal_edges(
268
287
  self, new_fact: AtomicFact, profile_id: str,
288
+ hub_cache: dict[str, int] | None = None,
269
289
  ) -> list[GraphEdge]:
270
290
  """CAUSAL edges: causal markers + shared entity. Direction: cause -> effect."""
271
291
  if not any(p.search(new_fact.content) for p in _CAUSAL_CUES):
272
292
  return []
273
293
  if not new_fact.canonical_entities:
274
294
  return []
295
+ if hub_cache is None:
296
+ hub_cache = {}
275
297
 
276
298
  edges: list[GraphEdge] = []
277
299
  seen: set[str] = set()
@@ -282,6 +304,8 @@ class GraphBuilder:
282
304
  break
283
305
  if other.fact_id == new_fact.fact_id or other.fact_id in seen:
284
306
  continue
307
+ if self._node_degree(other.fact_id, profile_id, hub_cache) >= self._MAX_HUB_DEGREE:
308
+ continue
285
309
  if self._edge_exists(other.fact_id, new_fact.fact_id, EdgeType.CAUSAL, profile_id):
286
310
  continue
287
311
  seen.add(other.fact_id)
@@ -36,7 +36,7 @@ class DaemonPoolProxy:
36
36
  envelopes — the adapter is responsible for surfacing those.
37
37
  """
38
38
 
39
- def __init__(self, port: int, *, timeout_s: float = 8.0) -> None: # v3.4.57: 60s8sprevents orphan flood from blocking daemon event loop
39
+ def __init__(self, port: int, *, timeout_s: float = 30.0) -> None: # v3.4.59: 8s30sobserved recall takes 13.4s on dense graph (2.1M edges); 8s always timed out → degraded mode
40
40
  self._port = port
41
41
  self._timeout = timeout_s
42
42
 
@@ -234,27 +234,47 @@ class SpreadingActivation:
234
234
  the highest-signal edges.
235
235
  """
236
236
  try:
237
+ # v3.4.59: LIMIT pushed inside each UNION branch so SQLite can use
238
+ # idx_edges_source_weight / idx_edges_target_weight and stop after
239
+ # max_neighbors_per_node rows per branch instead of materializing
240
+ # all 2.1M edges then sorting. Each branch wrapped in SELECT * FROM (...)
241
+ # because SQLite requires parentheses for ORDER BY+LIMIT in compound SELECTs.
242
+ lim = self._config.max_neighbors_per_node
237
243
  rows = self._db.execute(
238
244
  """
239
245
  SELECT neighbor_id, weight FROM (
240
- SELECT target_id AS neighbor_id, weight FROM graph_edges
246
+ SELECT * FROM (
247
+ SELECT target_id AS neighbor_id, weight FROM graph_edges
241
248
  WHERE source_id = ? AND profile_id = ?
249
+ ORDER BY weight DESC LIMIT ?
250
+ )
242
251
  UNION ALL
243
- SELECT target_fact_id AS neighbor_id, weight FROM association_edges
252
+ SELECT * FROM (
253
+ SELECT target_fact_id AS neighbor_id, weight FROM association_edges
244
254
  WHERE source_fact_id = ? AND profile_id = ?
255
+ ORDER BY weight DESC LIMIT ?
256
+ )
245
257
  UNION ALL
246
- SELECT source_id AS neighbor_id, weight FROM graph_edges
258
+ SELECT * FROM (
259
+ SELECT source_id AS neighbor_id, weight FROM graph_edges
247
260
  WHERE target_id = ? AND profile_id = ?
261
+ ORDER BY weight DESC LIMIT ?
262
+ )
248
263
  UNION ALL
249
- SELECT source_fact_id AS neighbor_id, weight FROM association_edges
264
+ SELECT * FROM (
265
+ SELECT source_fact_id AS neighbor_id, weight FROM association_edges
250
266
  WHERE target_fact_id = ? AND profile_id = ?
267
+ ORDER BY weight DESC LIMIT ?
268
+ )
251
269
  )
252
270
  ORDER BY weight DESC
253
271
  LIMIT ?
254
272
  """,
255
- (node_id, profile_id, node_id, profile_id,
256
- node_id, profile_id, node_id, profile_id,
257
- self._config.max_neighbors_per_node),
273
+ (node_id, profile_id, lim,
274
+ node_id, profile_id, lim,
275
+ node_id, profile_id, lim,
276
+ node_id, profile_id, lim,
277
+ lim),
258
278
  )
259
279
  return [
260
280
  (dict(r)["neighbor_id"], dict(r)["weight"]) for r in rows
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: superlocalmemory
3
- Version: 3.4.58
3
+ Version: 3.4.59
4
4
  Summary: Information-geometric agent memory with mathematical guarantees
5
5
  Author-email: Varun Pratap Bhardwaj <admin@superlocalmemory.com>
6
6
  License: AGPL-3.0-or-later