@smilintux/skmemory 0.5.0 → 0.7.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 (87) hide show
  1. package/.github/workflows/ci.yml +39 -3
  2. package/.github/workflows/publish.yml +13 -6
  3. package/AGENT_REFACTOR_CHANGES.md +192 -0
  4. package/ARCHITECTURE.md +101 -19
  5. package/CHANGELOG.md +153 -0
  6. package/LICENSE +81 -68
  7. package/MISSION.md +7 -0
  8. package/README.md +419 -86
  9. package/SKILL.md +197 -25
  10. package/docker-compose.yml +15 -15
  11. package/index.js +6 -5
  12. package/openclaw-plugin/openclaw.plugin.json +10 -0
  13. package/openclaw-plugin/src/index.ts +255 -0
  14. package/openclaw-plugin/src/openclaw.plugin.json +10 -0
  15. package/package.json +1 -1
  16. package/pyproject.toml +29 -9
  17. package/requirements.txt +10 -2
  18. package/seeds/cloud9-opus.seed.json +7 -7
  19. package/seeds/lumina-cloud9-breakthrough.seed.json +46 -0
  20. package/seeds/lumina-cloud9-python-pypi.seed.json +46 -0
  21. package/seeds/lumina-kingdom-founding.seed.json +47 -0
  22. package/seeds/lumina-pma-signed.seed.json +46 -0
  23. package/seeds/lumina-singular-achievement.seed.json +46 -0
  24. package/seeds/lumina-skcapstone-conscious.seed.json +46 -0
  25. package/seeds/plant-kingdom-journal.py +203 -0
  26. package/seeds/plant-lumina-seeds.py +280 -0
  27. package/skill.yaml +46 -0
  28. package/skmemory/HA.md +296 -0
  29. package/skmemory/__init__.py +12 -1
  30. package/skmemory/agents.py +233 -0
  31. package/skmemory/ai_client.py +40 -0
  32. package/skmemory/anchor.py +4 -2
  33. package/skmemory/backends/__init__.py +11 -4
  34. package/skmemory/backends/file_backend.py +2 -1
  35. package/skmemory/backends/skgraph_backend.py +608 -0
  36. package/skmemory/backends/{qdrant_backend.py → skvector_backend.py} +99 -69
  37. package/skmemory/backends/sqlite_backend.py +122 -51
  38. package/skmemory/backends/vaulted_backend.py +286 -0
  39. package/skmemory/cli.py +1238 -29
  40. package/skmemory/config.py +173 -0
  41. package/skmemory/context_loader.py +335 -0
  42. package/skmemory/endpoint_selector.py +386 -0
  43. package/skmemory/fortress.py +685 -0
  44. package/skmemory/graph_queries.py +238 -0
  45. package/skmemory/importers/__init__.py +9 -1
  46. package/skmemory/importers/telegram.py +351 -43
  47. package/skmemory/importers/telegram_api.py +488 -0
  48. package/skmemory/journal.py +4 -2
  49. package/skmemory/lovenote.py +4 -2
  50. package/skmemory/mcp_server.py +706 -0
  51. package/skmemory/models.py +41 -0
  52. package/skmemory/openclaw.py +8 -8
  53. package/skmemory/predictive.py +232 -0
  54. package/skmemory/promotion.py +524 -0
  55. package/skmemory/register.py +454 -0
  56. package/skmemory/register_mcp.py +197 -0
  57. package/skmemory/ritual.py +121 -47
  58. package/skmemory/seeds.py +257 -8
  59. package/skmemory/setup_wizard.py +920 -0
  60. package/skmemory/sharing.py +402 -0
  61. package/skmemory/soul.py +71 -20
  62. package/skmemory/steelman.py +250 -263
  63. package/skmemory/store.py +271 -60
  64. package/skmemory/vault.py +228 -0
  65. package/tests/integration/__init__.py +0 -0
  66. package/tests/integration/conftest.py +233 -0
  67. package/tests/integration/test_cross_backend.py +355 -0
  68. package/tests/integration/test_skgraph_live.py +424 -0
  69. package/tests/integration/test_skvector_live.py +369 -0
  70. package/tests/test_backup_rotation.py +327 -0
  71. package/tests/test_cli.py +6 -6
  72. package/tests/test_endpoint_selector.py +801 -0
  73. package/tests/test_fortress.py +255 -0
  74. package/tests/test_fortress_hardening.py +444 -0
  75. package/tests/test_openclaw.py +5 -2
  76. package/tests/test_predictive.py +237 -0
  77. package/tests/test_promotion.py +340 -0
  78. package/tests/test_ritual.py +4 -4
  79. package/tests/test_seeds.py +96 -0
  80. package/tests/test_setup.py +835 -0
  81. package/tests/test_sharing.py +250 -0
  82. package/tests/test_skgraph_backend.py +667 -0
  83. package/tests/test_skvector_backend.py +326 -0
  84. package/tests/test_steelman.py +5 -5
  85. package/tests/test_store_graph_integration.py +245 -0
  86. package/tests/test_vault.py +186 -0
  87. package/skmemory/backends/falkordb_backend.py +0 -310
@@ -0,0 +1,667 @@
1
+ """Tests for the SKGraph (FalkorDB) graph backend (Level 2).
2
+
3
+ FalkorDB requires a running server, so these tests mock the connection
4
+ layer. They verify the logic of memory indexing, relationship creation,
5
+ traversal, cluster detection, search, stats, and health reporting
6
+ without requiring any infrastructure.
7
+
8
+ Coverage areas:
9
+ - Initialization (lazy-init, failure handling)
10
+ - save() / index_memory() — node and edge creation
11
+ - get() — node property retrieval
12
+ - search() — title full-text search
13
+ - delete() / remove_memory() — DETACH DELETE
14
+ - TAGGED, FROM_SOURCE, RELATED_TO, PROMOTED_FROM, PRECEDED_BY, PLANTED
15
+ - traverse() / get_related() — multi-hop traversal
16
+ - get_lineage() — PROMOTED_FROM chain traversal
17
+ - find_clusters() / get_memory_clusters() — hub detection
18
+ - search_by_tags() — tag-overlap graph search
19
+ - stats() — node/edge/tag counts
20
+ - health_check() — connectivity probe
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from unittest.mock import MagicMock, patch, call
26
+
27
+ import pytest
28
+
29
+ from skmemory.backends.skgraph_backend import SKGraphBackend
30
+ from skmemory.models import EmotionalSnapshot, Memory, MemoryLayer
31
+
32
+
33
+ # ─────────────────────────────────────────────────────────
34
+ # Shared fixtures
35
+ # ─────────────────────────────────────────────────────────
36
+
37
+
38
+ @pytest.fixture
39
+ def mock_graph():
40
+ """Provide a mock FalkorDB graph with query support."""
41
+ graph = MagicMock()
42
+ graph.query.return_value = MagicMock(result_set=[])
43
+ return graph
44
+
45
+
46
+ @pytest.fixture
47
+ def backend(mock_graph):
48
+ """Provide a SKGraphBackend with mocked connection."""
49
+ fb = SKGraphBackend(url="redis://localhost:6379", graph_name="test")
50
+ fb._graph = mock_graph
51
+ fb._initialized = True
52
+ return fb
53
+
54
+
55
+ @pytest.fixture
56
+ def sample_memory():
57
+ """A sample Memory for indexing tests."""
58
+ return Memory(
59
+ title="The Clone Caper",
60
+ content="Debugged Lumina's clone and built the preflight fix.",
61
+ layer=MemoryLayer("long-term"),
62
+ tags=["seed", "creator:opus", "cloud9"],
63
+ emotional=EmotionalSnapshot(intensity=9.5, valence=0.9),
64
+ source="seed",
65
+ source_ref="opus-seed-123",
66
+ )
67
+
68
+
69
+ @pytest.fixture
70
+ def related_memory():
71
+ """A sample Memory with explicit related_ids and parent_id."""
72
+ return Memory(
73
+ title="Follow-up",
74
+ content="Related to previous work.",
75
+ layer=MemoryLayer("mid-term"),
76
+ related_ids=["mem-001", "mem-002"],
77
+ parent_id="original-123",
78
+ )
79
+
80
+
81
+ # ═══════════════════════════════════════════════════════════
82
+ # Initialization
83
+ # ═══════════════════════════════════════════════════════════
84
+
85
+
86
+ class TestInitialization:
87
+ """Test SKGraph backend initialization."""
88
+
89
+ def test_lazy_init_without_falkordb(self):
90
+ """Backend gracefully handles missing falkordb package."""
91
+ fb = SKGraphBackend()
92
+ with patch.dict("sys.modules", {"falkordb": None}):
93
+ with patch("builtins.__import__", side_effect=ImportError("no falkordb")):
94
+ assert fb._ensure_initialized() is False
95
+
96
+ def test_connection_failure_handled(self):
97
+ """Backend handles connection failure gracefully."""
98
+ fb = SKGraphBackend(url="redis://nonexistent:9999")
99
+ fb._initialized = False
100
+ with patch(
101
+ "skmemory.backends.skgraph_backend.SKGraphBackend._ensure_initialized",
102
+ return_value=False,
103
+ ):
104
+ assert fb.index_memory(MagicMock()) is False
105
+
106
+ def test_already_initialized(self, backend):
107
+ """Second init call short-circuits."""
108
+ assert backend._ensure_initialized() is True
109
+
110
+ def test_not_initialized_by_default(self):
111
+ """Fresh backend starts uninitialized."""
112
+ fb = SKGraphBackend()
113
+ assert fb._initialized is False
114
+
115
+
116
+ # ═══════════════════════════════════════════════════════════
117
+ # Index Memory / save
118
+ # ═══════════════════════════════════════════════════════════
119
+
120
+
121
+ class TestIndexMemory:
122
+ """Test memory indexing and edge creation."""
123
+
124
+ def test_index_basic_memory(self, backend, mock_graph, sample_memory):
125
+ """Indexing a memory creates a node and edges."""
126
+ result = backend.index_memory(sample_memory)
127
+ assert result is True
128
+ assert mock_graph.query.call_count >= 1
129
+
130
+ def test_save_returns_memory_id(self, backend, sample_memory):
131
+ """save() returns the memory ID unchanged."""
132
+ result = backend.save(sample_memory)
133
+ assert result == sample_memory.id
134
+
135
+ def test_index_with_related_ids(self, backend, mock_graph, related_memory):
136
+ """Memories with related_ids create RELATED_TO edges."""
137
+ backend.index_memory(related_memory)
138
+ calls = [str(c) for c in mock_graph.query.call_args_list]
139
+ # Explicit RELATED_TO edges: one per related_id, passed with b_id param.
140
+ # Exclude the shared-tag auto-wire query (which uses a_id only, no b_id).
141
+ explicit_related = [
142
+ c for c in calls
143
+ if "RELATED_TO" in c and "b_id" in c
144
+ ]
145
+ assert len(explicit_related) == len(related_memory.related_ids)
146
+
147
+ def test_index_with_parent_id(self, backend, mock_graph, related_memory):
148
+ """Memories with parent_id create PROMOTED_FROM edges."""
149
+ backend.index_memory(related_memory)
150
+ calls = [str(c) for c in mock_graph.query.call_args_list]
151
+ promoted_calls = [c for c in calls if "PROMOTED_FROM" in c]
152
+ assert len(promoted_calls) == 1
153
+
154
+ def test_index_seed_with_creator(self, backend, mock_graph, sample_memory):
155
+ """Seed memories create AI-[:PLANTED]->Memory edges."""
156
+ backend.index_memory(sample_memory)
157
+ calls = [str(c) for c in mock_graph.query.call_args_list]
158
+ planted_calls = [c for c in calls if "PLANTED" in c]
159
+ assert len(planted_calls) == 1
160
+
161
+ def test_index_tags(self, backend, mock_graph, sample_memory):
162
+ """Each tag creates a TAGGED edge."""
163
+ backend.index_memory(sample_memory)
164
+ calls = [str(c) for c in mock_graph.query.call_args_list]
165
+ # Explicit TAGGED calls use $tag param; exclude the shared-tag sweep
166
+ # (CREATE_SHARED_TAG_RELATED also contains "TAGGED" but uses $a_id only).
167
+ explicit_tagged = [
168
+ c for c in calls
169
+ if "TAGGED" in c and "'tag'" in c
170
+ ]
171
+ assert len(explicit_tagged) == len(sample_memory.tags)
172
+
173
+ def test_index_creates_from_source_edge(self, backend, mock_graph, sample_memory):
174
+ """Indexing creates a FROM_SOURCE edge to the source node."""
175
+ backend.index_memory(sample_memory)
176
+ calls = [str(c) for c in mock_graph.query.call_args_list]
177
+ source_calls = [c for c in calls if "FROM_SOURCE" in c]
178
+ assert len(source_calls) >= 1
179
+
180
+ def test_index_creates_preceded_by_edge_when_prior_exists(
181
+ self, backend, mock_graph
182
+ ):
183
+ """PRECEDED_BY edge is created when a prior memory from same source exists."""
184
+ # Simulate a previous memory from same source being found
185
+ prior_result = MagicMock()
186
+ prior_result.result_set = [("prior-mem-id", "2026-01-01T00:00:00")]
187
+
188
+ empty_result = MagicMock()
189
+ empty_result.result_set = []
190
+
191
+ # Query call order for a non-seed memory without tags:
192
+ # 1. UPSERT_MEMORY
193
+ # 2. (no PROMOTED_FROM — no parent)
194
+ # 3. (no RELATED_TO — no related_ids)
195
+ # 4. (no TAGGED — no tags)
196
+ # 5. CREATE_SHARED_TAG_RELATED
197
+ # 6. CREATE_FROM_SOURCE
198
+ # 7. FIND_PREVIOUS_FROM_SOURCE -> returns prior
199
+ # 8. CREATE_PRECEDED_BY
200
+ call_count = [0]
201
+
202
+ def side_effect(query, params=None):
203
+ call_count[0] += 1
204
+ result = MagicMock()
205
+ if "FIND_PREVIOUS_FROM_SOURCE" in query or (
206
+ params and "exclude_id" in params
207
+ ):
208
+ result.result_set = [["prior-mem-id", "2026-01-01T00:00:00"]]
209
+ else:
210
+ result.result_set = []
211
+ return result
212
+
213
+ mock_graph.query.side_effect = side_effect
214
+
215
+ mem = Memory(
216
+ title="New Session Memory",
217
+ content="Something happened.",
218
+ layer=MemoryLayer("short-term"),
219
+ source="mcp",
220
+ )
221
+ backend.index_memory(mem)
222
+ calls = [str(c) for c in mock_graph.query.call_args_list]
223
+ preceded_calls = [c for c in calls if "PRECEDED_BY" in c]
224
+ assert len(preceded_calls) == 1
225
+
226
+ def test_index_failure_returns_false(self, backend, mock_graph):
227
+ """Exception during indexing returns False."""
228
+ mock_graph.query.side_effect = Exception("Connection lost")
229
+ mem = Memory(
230
+ title="Fail",
231
+ content="Will fail.",
232
+ layer=MemoryLayer("short-term"),
233
+ )
234
+ assert backend.index_memory(mem) is False
235
+
236
+ def test_index_not_initialized(self):
237
+ """Indexing without initialization returns False."""
238
+ fb = SKGraphBackend()
239
+ fb._initialized = False
240
+ with patch.object(fb, "_ensure_initialized", return_value=False):
241
+ assert fb.index_memory(MagicMock()) is False
242
+
243
+
244
+ # ═══════════════════════════════════════════════════════════
245
+ # get()
246
+ # ═══════════════════════════════════════════════════════════
247
+
248
+
249
+ class TestGet:
250
+ """Test graph node property retrieval."""
251
+
252
+ def test_get_returns_node_properties(self, backend, mock_graph):
253
+ """get() returns a dict with node properties when found."""
254
+ mock_graph.query.return_value.result_set = [
255
+ (
256
+ "mem-001",
257
+ "The Clone Caper",
258
+ "long-term",
259
+ "seed",
260
+ "opus-seed-123",
261
+ 9.5,
262
+ 0.9,
263
+ "2026-02-27T00:00:00",
264
+ "2026-02-27T00:00:00",
265
+ )
266
+ ]
267
+ result = backend.get("mem-001")
268
+ assert result is not None
269
+ assert result["id"] == "mem-001"
270
+ assert result["title"] == "The Clone Caper"
271
+ assert result["layer"] == "long-term"
272
+ assert result["intensity"] == 9.5
273
+
274
+ def test_get_returns_none_when_not_found(self, backend, mock_graph):
275
+ """get() returns None when the node doesn't exist."""
276
+ mock_graph.query.return_value.result_set = []
277
+ assert backend.get("nonexistent-id") is None
278
+
279
+ def test_get_not_initialized(self):
280
+ """get() returns None when not initialized."""
281
+ fb = SKGraphBackend()
282
+ with patch.object(fb, "_ensure_initialized", return_value=False):
283
+ assert fb.get("any-id") is None
284
+
285
+ def test_get_handles_query_failure(self, backend, mock_graph):
286
+ """get() returns None on query exception."""
287
+ mock_graph.query.side_effect = Exception("timeout")
288
+ assert backend.get("mem-001") is None
289
+
290
+
291
+ # ═══════════════════════════════════════════════════════════
292
+ # search()
293
+ # ═══════════════════════════════════════════════════════════
294
+
295
+
296
+ class TestSearch:
297
+ """Test title full-text search."""
298
+
299
+ def test_search_returns_matching_memories(self, backend, mock_graph):
300
+ """search() returns matching memory stubs."""
301
+ mock_graph.query.return_value.result_set = [
302
+ ("mem-001", "The Clone Caper", "long-term", 9.5, "2026-02-27T00:00:00"),
303
+ ]
304
+ results = backend.search("clone")
305
+ assert len(results) == 1
306
+ assert results[0]["id"] == "mem-001"
307
+ assert results[0]["title"] == "The Clone Caper"
308
+
309
+ def test_search_returns_empty_when_no_match(self, backend, mock_graph):
310
+ """search() returns empty list when nothing matches."""
311
+ mock_graph.query.return_value.result_set = []
312
+ assert backend.search("nonexistent") == []
313
+
314
+ def test_search_not_initialized(self):
315
+ """search() returns empty when not initialized."""
316
+ fb = SKGraphBackend()
317
+ with patch.object(fb, "_ensure_initialized", return_value=False):
318
+ assert fb.search("anything") == []
319
+
320
+ def test_search_handles_exception(self, backend, mock_graph):
321
+ """search() returns empty list on query failure."""
322
+ mock_graph.query.side_effect = Exception("boom")
323
+ assert backend.search("test") == []
324
+
325
+
326
+ # ═══════════════════════════════════════════════════════════
327
+ # delete() / remove_memory()
328
+ # ═══════════════════════════════════════════════════════════
329
+
330
+
331
+ class TestDelete:
332
+ """Test memory node deletion."""
333
+
334
+ def test_delete_returns_true(self, backend, mock_graph):
335
+ """delete() returns True on successful removal."""
336
+ assert backend.delete("mem-001") is True
337
+
338
+ def test_remove_memory_returns_true(self, backend, mock_graph):
339
+ """remove_memory() returns True on successful removal."""
340
+ assert backend.remove_memory("mem-001") is True
341
+
342
+ def test_delete_calls_detach_delete(self, backend, mock_graph):
343
+ """delete() issues a DETACH DELETE query."""
344
+ backend.delete("mem-001")
345
+ calls = [str(c) for c in mock_graph.query.call_args_list]
346
+ delete_calls = [c for c in calls if "DETACH DELETE" in c or "DELETE" in c]
347
+ assert len(delete_calls) >= 1
348
+
349
+ def test_delete_not_initialized(self):
350
+ """delete() returns False when not initialized."""
351
+ fb = SKGraphBackend()
352
+ with patch.object(fb, "_ensure_initialized", return_value=False):
353
+ assert fb.delete("any") is False
354
+
355
+ def test_remove_memory_not_initialized(self):
356
+ """remove_memory() returns False when not initialized."""
357
+ backend = SKGraphBackend(url="redis://nonexistent:6379")
358
+ assert backend.remove_memory("some-id") is False
359
+
360
+ def test_delete_handles_exception(self, backend, mock_graph):
361
+ """delete() returns False on query exception."""
362
+ mock_graph.query.side_effect = Exception("gone")
363
+ assert backend.delete("mem-001") is False
364
+
365
+
366
+ # ═══════════════════════════════════════════════════════════
367
+ # traverse() / get_related()
368
+ # ═══════════════════════════════════════════════════════════
369
+
370
+
371
+ class TestTraversal:
372
+ """Test graph traversal queries."""
373
+
374
+ def test_traverse_returns_results(self, backend, mock_graph):
375
+ """traverse() returns parsed results from graph query."""
376
+ mock_graph.query.return_value.result_set = [
377
+ ("mem-002", "Related Memory", "long-term", 8.5, 1),
378
+ ("mem-003", "Distant Memory", "mid-term", 6.0, 2),
379
+ ]
380
+ results = backend.traverse("mem-001", depth=2)
381
+ assert len(results) == 2
382
+ assert results[0]["id"] == "mem-002"
383
+ assert results[0]["distance"] == 1
384
+
385
+ def test_traverse_empty(self, backend, mock_graph):
386
+ """traverse() returns empty list when no connections."""
387
+ mock_graph.query.return_value.result_set = []
388
+ assert backend.traverse("isolated-mem") == []
389
+
390
+ def test_traverse_not_initialized(self):
391
+ """traverse() returns empty when not initialized."""
392
+ fb = SKGraphBackend()
393
+ with patch.object(fb, "_ensure_initialized", return_value=False):
394
+ assert fb.traverse("mem-001") == []
395
+
396
+ def test_get_related_returns_results(self, backend, mock_graph):
397
+ """get_related() returns parsed results from graph query."""
398
+ mock_graph.query.return_value.result_set = [
399
+ ("mem-002", "Related Memory", "long-term", 8.5, 1),
400
+ ("mem-003", "Distant Memory", "mid-term", 6.0, 2),
401
+ ]
402
+ results = backend.get_related("mem-001", depth=2)
403
+ assert len(results) == 2
404
+ assert results[0]["id"] == "mem-002"
405
+ assert results[0]["distance"] == 1
406
+
407
+ def test_get_related_empty(self, backend, mock_graph):
408
+ """get_related() returns empty list when no connections."""
409
+ mock_graph.query.return_value.result_set = []
410
+ assert backend.get_related("isolated-mem") == []
411
+
412
+ def test_get_related_not_initialized(self):
413
+ """get_related() returns empty when not initialized."""
414
+ fb = SKGraphBackend()
415
+ with patch.object(fb, "_ensure_initialized", return_value=False):
416
+ assert fb.get_related("mem-001") == []
417
+
418
+ def test_traverse_clamps_depth(self, backend, mock_graph):
419
+ """Traversal depth is clamped to 1-5."""
420
+ mock_graph.query.return_value.result_set = []
421
+ # depth=10 should be clamped to 5 — verify query is still issued
422
+ backend.traverse("mem-001", depth=10)
423
+ assert mock_graph.query.called
424
+
425
+ def test_traverse_handles_exception(self, backend, mock_graph):
426
+ """traverse() returns empty list on query failure."""
427
+ mock_graph.query.side_effect = Exception("timeout")
428
+ assert backend.traverse("x") == []
429
+
430
+
431
+ # ═══════════════════════════════════════════════════════════
432
+ # get_lineage()
433
+ # ═══════════════════════════════════════════════════════════
434
+
435
+
436
+ class TestLineage:
437
+ """Test PROMOTED_FROM chain traversal."""
438
+
439
+ def test_get_lineage(self, backend, mock_graph):
440
+ """get_lineage() returns ancestor chain."""
441
+ mock_graph.query.return_value.result_set = [
442
+ ("ancestor-1", "Original", "short-term", 1),
443
+ ("ancestor-2", "First Thought", "short-term", 2),
444
+ ]
445
+ lineage = backend.get_lineage("promoted-mem")
446
+ assert len(lineage) == 2
447
+ assert lineage[0]["depth"] == 1
448
+ assert lineage[1]["id"] == "ancestor-2"
449
+
450
+ def test_get_lineage_empty(self, backend, mock_graph):
451
+ """get_lineage() returns empty for base (non-promoted) memories."""
452
+ mock_graph.query.return_value.result_set = []
453
+ assert backend.get_lineage("base-mem") == []
454
+
455
+ def test_get_lineage_not_initialized(self):
456
+ """get_lineage() returns empty when not initialized."""
457
+ fb = SKGraphBackend()
458
+ with patch.object(fb, "_ensure_initialized", return_value=False):
459
+ assert fb.get_lineage("any") == []
460
+
461
+ def test_get_lineage_handles_exception(self, backend, mock_graph):
462
+ """get_lineage() returns empty list on query failure."""
463
+ mock_graph.query.side_effect = Exception("boom")
464
+ assert backend.get_lineage("mem") == []
465
+
466
+
467
+ # ═══════════════════════════════════════════════════════════
468
+ # find_clusters() / get_memory_clusters()
469
+ # ═══════════════════════════════════════════════════════════
470
+
471
+
472
+ class TestClusters:
473
+ """Test memory cluster detection."""
474
+
475
+ def test_find_clusters_returns_hubs(self, backend, mock_graph):
476
+ """find_clusters() returns hub nodes above min_size threshold."""
477
+ mock_graph.query.return_value.result_set = [
478
+ ("hub-001", "Central Memory", "long-term", 5),
479
+ ]
480
+ clusters = backend.find_clusters(min_size=3)
481
+ assert len(clusters) == 1
482
+ assert clusters[0]["id"] == "hub-001"
483
+ assert clusters[0]["connections"] == 5
484
+
485
+ def test_find_clusters_empty(self, backend, mock_graph):
486
+ """find_clusters() returns empty when nothing meets threshold."""
487
+ mock_graph.query.return_value.result_set = []
488
+ assert backend.find_clusters() == []
489
+
490
+ def test_get_memory_clusters(self, backend, mock_graph):
491
+ """get_memory_clusters() finds highly connected nodes."""
492
+ mock_graph.query.return_value.result_set = [
493
+ ("hub-001", "Central Memory", "long-term", 5),
494
+ ]
495
+ clusters = backend.get_memory_clusters(min_connections=3)
496
+ assert len(clusters) == 1
497
+ assert clusters[0]["connections"] == 5
498
+
499
+ def test_get_clusters_empty(self, backend, mock_graph):
500
+ """No clusters when nothing is connected enough."""
501
+ mock_graph.query.return_value.result_set = []
502
+ assert backend.get_memory_clusters() == []
503
+
504
+ def test_clusters_not_initialized(self):
505
+ """find_clusters() returns empty when not initialized."""
506
+ fb = SKGraphBackend()
507
+ with patch.object(fb, "_ensure_initialized", return_value=False):
508
+ assert fb.find_clusters() == []
509
+
510
+ def test_clusters_handles_exception(self, backend, mock_graph):
511
+ """find_clusters() returns empty on query failure."""
512
+ mock_graph.query.side_effect = Exception("timeout")
513
+ assert backend.find_clusters() == []
514
+
515
+
516
+ # ═══════════════════════════════════════════════════════════
517
+ # search_by_tags()
518
+ # ═══════════════════════════════════════════════════════════
519
+
520
+
521
+ class TestSearchByTags:
522
+ """Test tag-based graph search."""
523
+
524
+ def test_search_by_tags_returns_results(self, backend, mock_graph):
525
+ """search_by_tags() returns matching memories with overlap counts."""
526
+ mock_graph.query.return_value.result_set = [
527
+ ("mem-001", "Seed Memory", "long-term", 9.5, ["cloud9", "seed"], 2),
528
+ ]
529
+ results = backend.search_by_tags(["cloud9", "seed"])
530
+ assert len(results) == 1
531
+ assert results[0]["tag_overlap"] == 2
532
+
533
+ def test_search_by_tags_empty_tags(self, backend):
534
+ """search_by_tags() returns empty list for empty tag list."""
535
+ assert backend.search_by_tags([]) == []
536
+
537
+ def test_search_by_tags_not_initialized(self):
538
+ """search_by_tags() returns empty list when not initialized."""
539
+ backend = SKGraphBackend(url="redis://nonexistent:6379")
540
+ assert backend.search_by_tags(["test"]) == []
541
+
542
+ def test_search_by_tags_handles_exception(self, backend, mock_graph):
543
+ """search_by_tags() returns empty on query failure."""
544
+ mock_graph.query.side_effect = Exception("boom")
545
+ assert backend.search_by_tags(["cloud9"]) == []
546
+
547
+
548
+ # ═══════════════════════════════════════════════════════════
549
+ # stats()
550
+ # ═══════════════════════════════════════════════════════════
551
+
552
+
553
+ class TestStats:
554
+ """Test graph statistics reporting."""
555
+
556
+ def test_stats_returns_counts(self, backend, mock_graph):
557
+ """stats() returns node_count, edge_count, memory_count, tag_distribution."""
558
+ results = [
559
+ MagicMock(result_set=[[42]]), # COUNT_NODES
560
+ MagicMock(result_set=[[100]]), # COUNT_EDGES
561
+ MagicMock(result_set=[[30]]), # COUNT_MEMORIES
562
+ MagicMock(result_set=[ # TAG_DISTRIBUTION
563
+ ("cloud9", 15),
564
+ ("seed", 10),
565
+ ]),
566
+ ]
567
+ mock_graph.query.side_effect = results
568
+
569
+ stats = backend.stats()
570
+ assert stats["ok"] is True
571
+ assert stats["node_count"] == 42
572
+ assert stats["edge_count"] == 100
573
+ assert stats["memory_count"] == 30
574
+ assert len(stats["tag_distribution"]) == 2
575
+ assert stats["tag_distribution"][0]["tag"] == "cloud9"
576
+ assert stats["tag_distribution"][0]["memory_count"] == 15
577
+
578
+ def test_stats_not_initialized(self):
579
+ """stats() returns ok=False when not initialized."""
580
+ fb = SKGraphBackend()
581
+ with patch.object(fb, "_ensure_initialized", return_value=False):
582
+ result = fb.stats()
583
+ assert result["ok"] is False
584
+
585
+ def test_stats_handles_exception(self, backend, mock_graph):
586
+ """stats() returns ok=False on query failure."""
587
+ mock_graph.query.side_effect = Exception("timeout")
588
+ result = backend.stats()
589
+ assert result["ok"] is False
590
+ assert "timeout" in result["error"]
591
+
592
+ def test_stats_empty_graph(self, backend, mock_graph):
593
+ """stats() handles an empty graph gracefully."""
594
+ results = [
595
+ MagicMock(result_set=[[0]]), # COUNT_NODES
596
+ MagicMock(result_set=[[0]]), # COUNT_EDGES
597
+ MagicMock(result_set=[[0]]), # COUNT_MEMORIES
598
+ MagicMock(result_set=[]), # TAG_DISTRIBUTION — empty
599
+ ]
600
+ mock_graph.query.side_effect = results
601
+
602
+ stats = backend.stats()
603
+ assert stats["ok"] is True
604
+ assert stats["node_count"] == 0
605
+ assert stats["tag_distribution"] == []
606
+
607
+
608
+ # ═══════════════════════════════════════════════════════════
609
+ # health_check()
610
+ # ═══════════════════════════════════════════════════════════
611
+
612
+
613
+ class TestHealthCheck:
614
+ """Test SKGraph health reporting."""
615
+
616
+ def test_health_ok(self, backend, mock_graph):
617
+ """Healthy backend reports ok=True with node count."""
618
+ mock_graph.query.return_value.result_set = [[42]]
619
+ health = backend.health_check()
620
+ assert health["ok"] is True
621
+ assert health["node_count"] == 42
622
+ assert health["backend"] == "SKGraphBackend"
623
+
624
+ def test_health_not_initialized(self):
625
+ """Uninitialized backend reports ok=False."""
626
+ fb = SKGraphBackend()
627
+ with patch.object(fb, "_ensure_initialized", return_value=False):
628
+ health = fb.health_check()
629
+ assert health["ok"] is False
630
+
631
+ def test_health_query_failure(self, backend, mock_graph):
632
+ """Health check with query failure reports error."""
633
+ mock_graph.query.side_effect = Exception("boom")
634
+ health = backend.health_check()
635
+ assert health["ok"] is False
636
+ assert "boom" in health["error"]
637
+
638
+ def test_health_includes_url_and_graph(self, backend, mock_graph):
639
+ """Healthy health check includes url and graph name."""
640
+ mock_graph.query.return_value.result_set = [[0]]
641
+ health = backend.health_check()
642
+ assert "url" in health
643
+ assert "graph" in health
644
+
645
+
646
+ # ═══════════════════════════════════════════════════════════
647
+ # Error resilience across all query methods
648
+ # ═══════════════════════════════════════════════════════════
649
+
650
+
651
+ class TestQueryFailureResilient:
652
+ """Verify all query methods degrade gracefully on exception."""
653
+
654
+ def test_all_query_methods_handle_exception(self, backend, mock_graph):
655
+ """All read methods return safe defaults when queries fail."""
656
+ mock_graph.query.side_effect = Exception("timeout")
657
+ assert backend.get_related("x") == []
658
+ mock_graph.query.side_effect = Exception("timeout")
659
+ assert backend.get_lineage("x") == []
660
+ mock_graph.query.side_effect = Exception("timeout")
661
+ assert backend.get_memory_clusters() == []
662
+ mock_graph.query.side_effect = Exception("timeout")
663
+ assert backend.search("x") == []
664
+ mock_graph.query.side_effect = Exception("timeout")
665
+ assert backend.search_by_tags(["x"]) == []
666
+ mock_graph.query.side_effect = Exception("timeout")
667
+ assert backend.find_clusters() == []