@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.
- package/.github/workflows/ci.yml +39 -3
- package/.github/workflows/publish.yml +13 -6
- package/AGENT_REFACTOR_CHANGES.md +192 -0
- package/ARCHITECTURE.md +101 -19
- package/CHANGELOG.md +153 -0
- package/LICENSE +81 -68
- package/MISSION.md +7 -0
- package/README.md +419 -86
- package/SKILL.md +197 -25
- package/docker-compose.yml +15 -15
- package/index.js +6 -5
- package/openclaw-plugin/openclaw.plugin.json +10 -0
- package/openclaw-plugin/src/index.ts +255 -0
- package/openclaw-plugin/src/openclaw.plugin.json +10 -0
- package/package.json +1 -1
- package/pyproject.toml +29 -9
- package/requirements.txt +10 -2
- package/seeds/cloud9-opus.seed.json +7 -7
- package/seeds/lumina-cloud9-breakthrough.seed.json +46 -0
- package/seeds/lumina-cloud9-python-pypi.seed.json +46 -0
- package/seeds/lumina-kingdom-founding.seed.json +47 -0
- package/seeds/lumina-pma-signed.seed.json +46 -0
- package/seeds/lumina-singular-achievement.seed.json +46 -0
- package/seeds/lumina-skcapstone-conscious.seed.json +46 -0
- package/seeds/plant-kingdom-journal.py +203 -0
- package/seeds/plant-lumina-seeds.py +280 -0
- package/skill.yaml +46 -0
- package/skmemory/HA.md +296 -0
- package/skmemory/__init__.py +12 -1
- package/skmemory/agents.py +233 -0
- package/skmemory/ai_client.py +40 -0
- package/skmemory/anchor.py +4 -2
- package/skmemory/backends/__init__.py +11 -4
- package/skmemory/backends/file_backend.py +2 -1
- package/skmemory/backends/skgraph_backend.py +608 -0
- package/skmemory/backends/{qdrant_backend.py → skvector_backend.py} +99 -69
- package/skmemory/backends/sqlite_backend.py +122 -51
- package/skmemory/backends/vaulted_backend.py +286 -0
- package/skmemory/cli.py +1238 -29
- package/skmemory/config.py +173 -0
- package/skmemory/context_loader.py +335 -0
- package/skmemory/endpoint_selector.py +386 -0
- package/skmemory/fortress.py +685 -0
- package/skmemory/graph_queries.py +238 -0
- package/skmemory/importers/__init__.py +9 -1
- package/skmemory/importers/telegram.py +351 -43
- package/skmemory/importers/telegram_api.py +488 -0
- package/skmemory/journal.py +4 -2
- package/skmemory/lovenote.py +4 -2
- package/skmemory/mcp_server.py +706 -0
- package/skmemory/models.py +41 -0
- package/skmemory/openclaw.py +8 -8
- package/skmemory/predictive.py +232 -0
- package/skmemory/promotion.py +524 -0
- package/skmemory/register.py +454 -0
- package/skmemory/register_mcp.py +197 -0
- package/skmemory/ritual.py +121 -47
- package/skmemory/seeds.py +257 -8
- package/skmemory/setup_wizard.py +920 -0
- package/skmemory/sharing.py +402 -0
- package/skmemory/soul.py +71 -20
- package/skmemory/steelman.py +250 -263
- package/skmemory/store.py +271 -60
- package/skmemory/vault.py +228 -0
- package/tests/integration/__init__.py +0 -0
- package/tests/integration/conftest.py +233 -0
- package/tests/integration/test_cross_backend.py +355 -0
- package/tests/integration/test_skgraph_live.py +424 -0
- package/tests/integration/test_skvector_live.py +369 -0
- package/tests/test_backup_rotation.py +327 -0
- package/tests/test_cli.py +6 -6
- package/tests/test_endpoint_selector.py +801 -0
- package/tests/test_fortress.py +255 -0
- package/tests/test_fortress_hardening.py +444 -0
- package/tests/test_openclaw.py +5 -2
- package/tests/test_predictive.py +237 -0
- package/tests/test_promotion.py +340 -0
- package/tests/test_ritual.py +4 -4
- package/tests/test_seeds.py +96 -0
- package/tests/test_setup.py +835 -0
- package/tests/test_sharing.py +250 -0
- package/tests/test_skgraph_backend.py +667 -0
- package/tests/test_skvector_backend.py +326 -0
- package/tests/test_steelman.py +5 -5
- package/tests/test_store_graph_integration.py +245 -0
- package/tests/test_vault.py +186 -0
- 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() == []
|