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