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 +60 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/superlocalmemory/__init__.py +1 -1
- package/src/superlocalmemory/core/graph_pruner.py +92 -2
- package/src/superlocalmemory/encoding/graph_builder.py +32 -8
- package/src/superlocalmemory/mcp/_daemon_proxy.py +1 -1
- package/src/superlocalmemory/retrieval/spreading_activation.py +27 -7
- package/src/superlocalmemory.egg-info/PKG-INFO +1 -1
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.
|
|
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
|
@@ -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.
|
|
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=
|
|
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
|
-
#
|
|
146
|
-
#
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
#
|
|
265
|
-
_MAX_CAUSAL_EDGES_PER_ENTITY: int =
|
|
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 =
|
|
39
|
+
def __init__(self, port: int, *, timeout_s: float = 30.0) -> None: # v3.4.59: 8s→30s — observed 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
|
|
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
|
|
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
|
|
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
|
|
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,
|
|
256
|
-
node_id, profile_id,
|
|
257
|
-
|
|
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
|