@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,237 @@
1
+ """Tests for the AMK-inspired predictive memory recall module.
2
+
3
+ Covers access logging, co-occurrence learning, tag affinity,
4
+ prediction ranking, and persistence.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import time
11
+ from pathlib import Path
12
+
13
+ import pytest
14
+
15
+ from skmemory.predictive import AccessEvent, PredictiveRecall
16
+
17
+
18
+ @pytest.fixture
19
+ def recall(tmp_path: Path) -> PredictiveRecall:
20
+ """Provide a PredictiveRecall with temp storage."""
21
+ return PredictiveRecall(log_path=tmp_path / "access_log.json")
22
+
23
+
24
+ class TestAccessLogging:
25
+ """Test access event recording."""
26
+
27
+ def test_log_access_creates_event(self, recall: PredictiveRecall):
28
+ """Logging an access adds to the event list."""
29
+ recall.log_access("mem-001", tags=["cloud9"])
30
+ assert recall._frequency["mem-001"] == 1
31
+
32
+ def test_log_multiple_accesses(self, recall: PredictiveRecall):
33
+ """Multiple accesses increase frequency."""
34
+ for _ in range(5):
35
+ recall.log_access("mem-002")
36
+ assert recall._frequency["mem-002"] == 5
37
+
38
+ def test_log_persists_to_disk(self, recall: PredictiveRecall):
39
+ """Access log is written to disk."""
40
+ recall.log_access("mem-003")
41
+ assert recall._log_path.exists()
42
+ data = json.loads(recall._log_path.read_text())
43
+ assert len(data) == 1
44
+ assert data[0]["memory_id"] == "mem-003"
45
+
46
+ def test_log_reloads_from_disk(self, tmp_path: Path):
47
+ """Access log survives re-creation."""
48
+ path = tmp_path / "access_log.json"
49
+ r1 = PredictiveRecall(log_path=path)
50
+ r1.log_access("mem-004", tags=["test"])
51
+ r1.log_access("mem-005", tags=["test"])
52
+
53
+ r2 = PredictiveRecall(log_path=path)
54
+ r2._ensure_loaded()
55
+ assert len(r2._events) == 2
56
+
57
+ def test_max_events_pruning(self, tmp_path: Path):
58
+ """Events are pruned when exceeding max_events."""
59
+ r = PredictiveRecall(log_path=tmp_path / "log.json", max_events=10)
60
+ for i in range(20):
61
+ r.log_access(f"mem-{i:03d}")
62
+ assert len(r._events) <= 10
63
+
64
+
65
+ class TestCooccurrence:
66
+ """Test co-occurrence pattern learning."""
67
+
68
+ def test_cooccurrence_within_session(self, recall: PredictiveRecall):
69
+ """Memories accessed close together build co-occurrence."""
70
+ now = time.time()
71
+ recall._events = [
72
+ AccessEvent(memory_id="A", timestamp=now),
73
+ AccessEvent(memory_id="B", timestamp=now + 1),
74
+ AccessEvent(memory_id="C", timestamp=now + 2),
75
+ ]
76
+ recall._rebuild_indices()
77
+ assert recall._cooccurrence["A"]["B"] > 0
78
+ assert recall._cooccurrence["A"]["C"] > 0
79
+ assert recall._cooccurrence["B"]["C"] > 0
80
+
81
+ def test_no_cooccurrence_across_sessions(self, recall: PredictiveRecall):
82
+ """Memories in different sessions don't co-occur."""
83
+ now = time.time()
84
+ recall._events = [
85
+ AccessEvent(memory_id="A", timestamp=now),
86
+ AccessEvent(memory_id="B", timestamp=now + 600),
87
+ ]
88
+ recall._rebuild_indices()
89
+ assert recall._cooccurrence["A"].get("B", 0) == 0
90
+
91
+
92
+ class TestTagAffinity:
93
+ """Test tag-based prediction."""
94
+
95
+ def test_tag_affinity_builds(self, recall: PredictiveRecall):
96
+ """Tags on accessed memories build affinity scores."""
97
+ recall.log_access("mem-001", tags=["cloud9", "love"])
98
+ recall.log_access("mem-002", tags=["cloud9", "trust"])
99
+ recall.log_access("mem-003", tags=["cloud9"])
100
+
101
+ assert recall._tag_affinity["cloud9"]["mem-001"] == 1
102
+ assert recall._tag_affinity["cloud9"]["mem-003"] == 1
103
+
104
+
105
+ class TestPrediction:
106
+ """Test the prediction engine."""
107
+
108
+ def test_predict_from_cooccurrence(self, recall: PredictiveRecall):
109
+ """Predictions include co-occurring memories."""
110
+ now = time.time()
111
+ recall._events = [
112
+ AccessEvent(memory_id="A", timestamp=now),
113
+ AccessEvent(memory_id="B", timestamp=now + 1),
114
+ AccessEvent(memory_id="A", timestamp=now + 100),
115
+ AccessEvent(memory_id="B", timestamp=now + 101),
116
+ ]
117
+ recall._rebuild_indices()
118
+
119
+ predictions = recall.predict(recent_ids=["A"])
120
+ ids = [p["memory_id"] for p in predictions]
121
+ assert "B" in ids
122
+
123
+ def test_predict_from_tags(self, recall: PredictiveRecall):
124
+ """Predictions include tag-affiliated memories."""
125
+ recall.log_access("mem-010", tags=["kingdom"])
126
+ recall.log_access("mem-011", tags=["kingdom"])
127
+ recall.log_access("mem-012", tags=["kingdom"])
128
+
129
+ predictions = recall.predict(active_tags=["kingdom"])
130
+ ids = [p["memory_id"] for p in predictions]
131
+ assert len(ids) > 0
132
+
133
+ def test_predict_excludes_recent(self, recall: PredictiveRecall):
134
+ """Already-accessed memories are excluded from predictions."""
135
+ now = time.time()
136
+ recall._events = [
137
+ AccessEvent(memory_id="A", timestamp=now),
138
+ AccessEvent(memory_id="B", timestamp=now + 1),
139
+ ]
140
+ recall._rebuild_indices()
141
+
142
+ predictions = recall.predict(recent_ids=["A", "B"])
143
+ ids = [p["memory_id"] for p in predictions]
144
+ assert "A" not in ids
145
+ assert "B" not in ids
146
+
147
+ def test_predict_empty_returns_empty(self, recall: PredictiveRecall):
148
+ """No data = no predictions."""
149
+ assert recall.predict() == []
150
+
151
+ def test_predict_limit(self, recall: PredictiveRecall):
152
+ """Predictions respect the limit parameter."""
153
+ for i in range(20):
154
+ recall.log_access(f"mem-{i:03d}", tags=["bulk"])
155
+
156
+ predictions = recall.predict(active_tags=["bulk"], limit=5)
157
+ assert len(predictions) <= 5
158
+
159
+ def test_predictions_have_reasons(self, recall: PredictiveRecall):
160
+ """Each prediction explains why it was chosen."""
161
+ recall.log_access("mem-X", tags=["reason-test"])
162
+ predictions = recall.predict(active_tags=["reason-test"])
163
+ if predictions:
164
+ assert len(predictions[0]["reasons"]) > 0
165
+
166
+
167
+ class TestStats:
168
+ """Test statistics reporting."""
169
+
170
+ def test_stats_empty(self, recall: PredictiveRecall):
171
+ """Stats work with no data."""
172
+ stats = recall.get_stats()
173
+ assert stats["total_events"] == 0
174
+ assert stats["unique_memories"] == 0
175
+
176
+ def test_stats_populated(self, recall: PredictiveRecall):
177
+ """Stats reflect recorded events."""
178
+ recall.log_access("mem-A", tags=["x"])
179
+ recall.log_access("mem-B", tags=["x"])
180
+ recall.log_access("mem-A", tags=["x"])
181
+ stats = recall.get_stats()
182
+ assert stats["total_events"] == 3
183
+ assert stats["unique_memories"] == 2
184
+
185
+
186
+ class TestIntegrity:
187
+ """Test AMK-inspired memory integrity features."""
188
+
189
+ def test_memory_seal_and_verify(self):
190
+ """Memory seal + verify roundtrip works."""
191
+ from skmemory.models import EmotionalSnapshot, Memory, MemoryLayer
192
+
193
+ mem = Memory(
194
+ title="Integrity Test",
195
+ content="This memory can prove it hasn't been tampered with.",
196
+ layer=MemoryLayer("long-term"),
197
+ emotional=EmotionalSnapshot(intensity=8.0),
198
+ )
199
+ mem.seal()
200
+ assert mem.integrity_hash != ""
201
+ assert mem.verify_integrity() is True
202
+
203
+ def test_tampered_memory_fails_verification(self):
204
+ """Altered content fails integrity check."""
205
+ from skmemory.models import Memory, MemoryLayer
206
+
207
+ mem = Memory(
208
+ title="Tamper Test",
209
+ content="Original content.",
210
+ layer=MemoryLayer("long-term"),
211
+ )
212
+ mem.seal()
213
+ mem.content = "Tampered content!"
214
+ assert mem.verify_integrity() is False
215
+
216
+ def test_unsealed_memory_passes(self):
217
+ """Memories without integrity hash pass by default."""
218
+ from skmemory.models import Memory, MemoryLayer
219
+
220
+ mem = Memory(
221
+ title="Unsealed",
222
+ content="No hash yet.",
223
+ layer=MemoryLayer("short-term"),
224
+ )
225
+ assert mem.verify_integrity() is True
226
+
227
+ def test_intent_field_stored(self):
228
+ """Memory intent field captures WHY."""
229
+ from skmemory.models import Memory, MemoryLayer
230
+
231
+ mem = Memory(
232
+ title="Intent Test",
233
+ content="Some content",
234
+ layer=MemoryLayer("mid-term"),
235
+ intent="Stored because Chef asked me to remember this moment.",
236
+ )
237
+ assert "Chef" in mem.intent
@@ -0,0 +1,347 @@
1
+ """Tests for the SKMemory auto-promotion engine."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+ from datetime import datetime, timedelta, timezone
7
+ from pathlib import Path
8
+
9
+ import pytest
10
+
11
+ from skmemory.models import EmotionalSnapshot, Memory, MemoryLayer
12
+ from skmemory.promotion import (
13
+ PromotionCriteria,
14
+ PromotionEngine,
15
+ PromotionResult,
16
+ PromotionScheduler,
17
+ )
18
+ from skmemory.store import MemoryStore
19
+
20
+
21
+ @pytest.fixture()
22
+ def store(tmp_path: Path) -> MemoryStore:
23
+ """Fresh MemoryStore with test memories."""
24
+ from skmemory.backends.file_backend import FileBackend
25
+
26
+ backend = FileBackend(base_path=tmp_path / "memories")
27
+ s = MemoryStore(primary=backend)
28
+
29
+ s.snapshot(
30
+ title="High intensity moment",
31
+ content="A breakthrough moment of deep connection",
32
+ layer=MemoryLayer.SHORT,
33
+ emotional=EmotionalSnapshot(intensity=8.5, valence=0.9, labels=["joy", "love"]),
34
+ tags=["milestone"],
35
+ )
36
+
37
+ old_time = (datetime.now(timezone.utc) - timedelta(hours=48)).isoformat()
38
+ m = Memory(
39
+ title="Frequently accessed",
40
+ content="Something accessed many times",
41
+ layer=MemoryLayer.SHORT,
42
+ created_at=old_time,
43
+ metadata={"access_count": 5},
44
+ )
45
+ s.primary.save(m)
46
+
47
+ s.snapshot(
48
+ title="Routine note",
49
+ content="Just a regular note",
50
+ layer=MemoryLayer.SHORT,
51
+ emotional=EmotionalSnapshot(intensity=1.0),
52
+ )
53
+
54
+ s.snapshot(
55
+ title="Important mid-term",
56
+ content="A mid-term memory with high emotional weight",
57
+ layer=MemoryLayer.MID,
58
+ emotional=EmotionalSnapshot(
59
+ intensity=9.0, valence=0.95, labels=["love"], cloud9_achieved=True
60
+ ),
61
+ tags=["cloud9:achieved"],
62
+ )
63
+
64
+ s.snapshot(
65
+ title="Regular mid-term",
66
+ content="Mid-term without special tags",
67
+ layer=MemoryLayer.MID,
68
+ emotional=EmotionalSnapshot(intensity=3.0),
69
+ )
70
+
71
+ return s
72
+
73
+
74
+ @pytest.fixture()
75
+ def engine(store: MemoryStore) -> PromotionEngine:
76
+ """PromotionEngine with default criteria."""
77
+ return PromotionEngine(store=store)
78
+
79
+
80
+ class TestPromotionCriteria:
81
+ """Tests for the criteria model."""
82
+
83
+ def test_defaults(self) -> None:
84
+ """Default criteria have sensible values."""
85
+ c = PromotionCriteria()
86
+ assert c.short_to_mid_intensity == 5.0
87
+ assert c.mid_to_long_intensity == 7.0
88
+ assert c.cloud9_auto_promote is True
89
+ assert c.max_promotions_per_sweep == 50
90
+
91
+ def test_custom_criteria(self) -> None:
92
+ """Custom criteria override defaults."""
93
+ c = PromotionCriteria(short_to_mid_intensity=3.0, max_promotions_per_sweep=10)
94
+ assert c.short_to_mid_intensity == 3.0
95
+ assert c.max_promotions_per_sweep == 10
96
+
97
+
98
+ class TestEvaluate:
99
+ """Tests for individual memory evaluation."""
100
+
101
+ def test_high_intensity_short_qualifies(self, engine: PromotionEngine) -> None:
102
+ """High-intensity short-term memory qualifies for mid-term."""
103
+ m = Memory(
104
+ title="Intense",
105
+ content="Very intense moment",
106
+ layer=MemoryLayer.SHORT,
107
+ emotional=EmotionalSnapshot(intensity=8.0),
108
+ )
109
+ assert engine.evaluate(m) == MemoryLayer.MID
110
+
111
+ def test_low_intensity_short_skipped(self, engine: PromotionEngine) -> None:
112
+ """Low-intensity short-term memory doesn't qualify."""
113
+ m = Memory(
114
+ title="Meh",
115
+ content="Nothing special",
116
+ layer=MemoryLayer.SHORT,
117
+ emotional=EmotionalSnapshot(intensity=1.0),
118
+ )
119
+ assert engine.evaluate(m) is None
120
+
121
+ def test_cloud9_auto_promotes(self, engine: PromotionEngine) -> None:
122
+ """Cloud 9 achieved memory auto-promotes."""
123
+ m = Memory(
124
+ title="Cloud 9",
125
+ content="Peak moment",
126
+ layer=MemoryLayer.SHORT,
127
+ emotional=EmotionalSnapshot(intensity=3.0, cloud9_achieved=True),
128
+ )
129
+ assert engine.evaluate(m) == MemoryLayer.MID
130
+
131
+ def test_mid_with_qualifying_tag(self, engine: PromotionEngine) -> None:
132
+ """Mid-term memory with qualifying tag promotes to long-term."""
133
+ m = Memory(
134
+ title="Tagged",
135
+ content="Important tagged memory",
136
+ layer=MemoryLayer.MID,
137
+ tags=["milestone"],
138
+ emotional=EmotionalSnapshot(intensity=4.0),
139
+ )
140
+ assert engine.evaluate(m) == MemoryLayer.LONG
141
+
142
+ def test_long_term_not_promoted(self, engine: PromotionEngine) -> None:
143
+ """Long-term memories are not promoted further."""
144
+ m = Memory(
145
+ title="Already long",
146
+ content="Already at the top",
147
+ layer=MemoryLayer.LONG,
148
+ emotional=EmotionalSnapshot(intensity=10.0),
149
+ )
150
+ assert engine.evaluate(m) is None
151
+
152
+ def test_old_frequently_accessed_qualifies(self, engine: PromotionEngine) -> None:
153
+ """Old memory with high access count qualifies."""
154
+ old_time = (datetime.now(timezone.utc) - timedelta(hours=48)).isoformat()
155
+ m = Memory(
156
+ title="Old and popular",
157
+ content="Accessed often",
158
+ layer=MemoryLayer.SHORT,
159
+ created_at=old_time,
160
+ metadata={"access_count": 5},
161
+ emotional=EmotionalSnapshot(intensity=2.0),
162
+ )
163
+ assert engine.evaluate(m) == MemoryLayer.MID
164
+
165
+
166
+ class TestSweep:
167
+ """Tests for the full promotion sweep."""
168
+
169
+ def test_sweep_promotes_qualifying(self, engine: PromotionEngine) -> None:
170
+ """Sweep promotes memories that meet criteria."""
171
+ result = engine.sweep()
172
+
173
+ assert result.total_promoted > 0
174
+ assert result.short_to_mid >= 1
175
+ assert result.mid_to_long >= 1
176
+ assert len(result.promoted_ids) == result.total_promoted
177
+
178
+ def test_sweep_respects_max(self, store: MemoryStore) -> None:
179
+ """Sweep respects max_promotions_per_sweep."""
180
+ criteria = PromotionCriteria(
181
+ short_to_mid_intensity=0.1,
182
+ max_promotions_per_sweep=1,
183
+ )
184
+ engine = PromotionEngine(store=store, criteria=criteria)
185
+ result = engine.sweep()
186
+
187
+ assert result.short_to_mid <= 1
188
+
189
+ def test_sweep_result_summary(self, engine: PromotionEngine) -> None:
190
+ """Result summary is human-readable."""
191
+ result = engine.sweep()
192
+ summary = result.summary()
193
+ assert "promoted" in summary
194
+ assert "skipped" in summary
195
+
196
+ def test_empty_store_sweep(self, tmp_path: Path) -> None:
197
+ """Sweep on empty store produces zero promotions."""
198
+ from skmemory.backends.file_backend import FileBackend
199
+
200
+ backend = FileBackend(base_path=tmp_path / "empty")
201
+ empty_store = MemoryStore(primary=backend)
202
+ engine = PromotionEngine(store=empty_store)
203
+
204
+ result = engine.sweep()
205
+ assert result.total_promoted == 0
206
+ assert result.errors == 0
207
+
208
+
209
+ class TestPromoteMemory:
210
+ """Tests for individual memory promotion."""
211
+
212
+ def test_promote_creates_new_memory(self, engine: PromotionEngine, store: MemoryStore) -> None:
213
+ """Promoting a memory creates a new one at the target tier."""
214
+ short_memories = store.list_memories(layer=MemoryLayer.SHORT)
215
+ intense = next(m for m in short_memories if m.emotional.intensity >= 5.0)
216
+
217
+ promoted = engine.promote_memory(intense, MemoryLayer.MID)
218
+ assert promoted is not None
219
+ assert promoted.layer == MemoryLayer.MID
220
+ assert promoted.parent_id == intense.id
221
+ assert "auto-promoted" in promoted.tags
222
+
223
+ def test_promoted_has_summary(self, engine: PromotionEngine, store: MemoryStore) -> None:
224
+ """Promoted memory has a generated summary."""
225
+ short_memories = store.list_memories(layer=MemoryLayer.SHORT)
226
+ intense = next(m for m in short_memories if m.emotional.intensity >= 5.0)
227
+
228
+ promoted = engine.promote_memory(intense, MemoryLayer.MID)
229
+ assert promoted.summary != ""
230
+
231
+ def test_promoted_has_metadata(self, engine: PromotionEngine, store: MemoryStore) -> None:
232
+ """Promoted memory has promotion metadata."""
233
+ short_memories = store.list_memories(layer=MemoryLayer.SHORT)
234
+ intense = next(m for m in short_memories if m.emotional.intensity >= 5.0)
235
+
236
+ promoted = engine.promote_memory(intense, MemoryLayer.MID)
237
+ assert "promoted_from" in promoted.metadata
238
+ assert "promoted_at" in promoted.metadata
239
+ assert "promotion_reason" in promoted.metadata
240
+
241
+
242
+ class TestPromotionResult:
243
+ """Tests for the result model."""
244
+
245
+ def test_total_promoted(self) -> None:
246
+ """total_promoted sums both transitions."""
247
+ r = PromotionResult(short_to_mid=3, mid_to_long=2)
248
+ assert r.total_promoted == 5
249
+
250
+
251
+ class TestRePromotionGuard:
252
+ """Ensure already-promoted memories are not promoted again."""
253
+
254
+ def test_source_marked_after_promotion(
255
+ self, engine: PromotionEngine, store: MemoryStore
256
+ ) -> None:
257
+ """After promotion, the source memory has 'promoted_to' in metadata."""
258
+ short_mems = store.list_memories(layer=MemoryLayer.SHORT)
259
+ intense = next(m for m in short_mems if m.emotional.intensity >= 5.0)
260
+
261
+ engine.promote_memory(intense, MemoryLayer.MID)
262
+
263
+ # Reload from store to confirm mutation was persisted
264
+ reloaded = store.recall(intense.id)
265
+ assert reloaded is not None
266
+ assert reloaded.metadata.get("promoted_to") == MemoryLayer.MID.value
267
+ assert "promoted" in reloaded.tags
268
+
269
+ def test_promoted_memory_not_re_promoted(
270
+ self, engine: PromotionEngine, store: MemoryStore
271
+ ) -> None:
272
+ """Running sweep twice doesn't double-promote the same memory."""
273
+ engine.sweep()
274
+ result2 = engine.sweep()
275
+
276
+ # Second sweep should find nothing new to promote
277
+ assert result2.total_promoted == 0
278
+
279
+ def test_evaluate_skips_already_promoted(self, engine: PromotionEngine) -> None:
280
+ """evaluate() returns None for a memory already marked as promoted."""
281
+ m = Memory(
282
+ title="Already done",
283
+ content="This was already promoted",
284
+ layer=MemoryLayer.SHORT,
285
+ emotional=EmotionalSnapshot(intensity=9.0),
286
+ metadata={"promoted_to": "mid-term"},
287
+ )
288
+ assert engine.evaluate(m) is None
289
+
290
+
291
+ class TestPromotionScheduler:
292
+ """Tests for the background PromotionScheduler."""
293
+
294
+ def test_run_once_returns_result(self, store: MemoryStore) -> None:
295
+ """run_once() returns a PromotionResult synchronously."""
296
+ scheduler = PromotionScheduler(store, interval_seconds=9999)
297
+ result = scheduler.run_once()
298
+ assert isinstance(result, PromotionResult)
299
+ assert scheduler.sweep_count == 1
300
+ assert scheduler.last_result is result
301
+
302
+ def test_start_stop(self, store: MemoryStore) -> None:
303
+ """Scheduler starts and stops the background thread cleanly."""
304
+ scheduler = PromotionScheduler(store, interval_seconds=9999)
305
+ assert not scheduler.is_running()
306
+
307
+ scheduler.start()
308
+ assert scheduler.is_running()
309
+
310
+ scheduler.stop(timeout=2.0)
311
+ assert not scheduler.is_running()
312
+
313
+ def test_start_idempotent(self, store: MemoryStore) -> None:
314
+ """Calling start() twice doesn't spawn a second thread."""
315
+ scheduler = PromotionScheduler(store, interval_seconds=9999)
316
+ scheduler.start()
317
+ thread_id = scheduler._thread.ident
318
+
319
+ scheduler.start() # second call should be a no-op
320
+ assert scheduler._thread.ident == thread_id
321
+
322
+ scheduler.stop(timeout=2.0)
323
+
324
+ def test_background_sweep_executes(self, store: MemoryStore) -> None:
325
+ """Background thread runs at least one sweep in the first few seconds."""
326
+ # Very short interval so the first sweep fires immediately in _run()
327
+ scheduler = PromotionScheduler(store, interval_seconds=0.01)
328
+ scheduler.start()
329
+ time.sleep(0.2)
330
+ scheduler.stop(timeout=2.0)
331
+
332
+ assert scheduler.sweep_count >= 1
333
+
334
+ def test_status_dict(self, store: MemoryStore) -> None:
335
+ """status() returns expected keys."""
336
+ scheduler = PromotionScheduler(store, interval_seconds=3600)
337
+ s = scheduler.status()
338
+ assert "running" in s
339
+ assert "sweep_count" in s
340
+ assert "interval_hours" in s
341
+ assert s["interval_hours"] == pytest.approx(1.0)
342
+ assert s["last_sweep"] is None # nothing run yet
343
+
344
+ def test_interval_hours_property(self, store: MemoryStore) -> None:
345
+ """interval_hours converts correctly from seconds."""
346
+ scheduler = PromotionScheduler(store, interval_seconds=7200)
347
+ assert scheduler.interval_hours == pytest.approx(2.0)
@@ -1,8 +1,6 @@
1
1
  """Tests for the Quadrant Memory Split module."""
2
2
 
3
- import pytest
4
-
5
- from skmemory.models import EmotionalSnapshot, Memory, MemoryLayer, MemoryRole
3
+ from skmemory.models import EmotionalSnapshot, Memory, MemoryRole
6
4
  from skmemory.quadrants import (
7
5
  Quadrant,
8
6
  classify_memory,
@@ -137,7 +135,11 @@ class TestQuadrantStats:
137
135
  memories = [
138
136
  Memory(title="Identity", content="Who I am", source="seed", tags=["seed"]),
139
137
  Memory(title="Bug", content="Fixed deploy code in database", role=MemoryRole.DEV),
140
- Memory(title="Love", content="Cloud 9 love breakthrough", emotional=EmotionalSnapshot(intensity=10.0, cloud9_achieved=True)),
138
+ Memory(
139
+ title="Love",
140
+ content="Cloud 9 love breakthrough",
141
+ emotional=EmotionalSnapshot(intensity=10.0, cloud9_achieved=True),
142
+ ),
141
143
  Memory(title="Idea", content="What if crazy brainstorm experiment"),
142
144
  ]
143
145
  stats = get_quadrant_stats(memories)
@@ -158,7 +160,11 @@ class TestFilterByQuadrant:
158
160
  def test_filter_soul(self) -> None:
159
161
  """Filter returns only SOUL memories."""
160
162
  memories = [
161
- Memory(title="Love", content="Cloud 9 love", emotional=EmotionalSnapshot(intensity=10.0, cloud9_achieved=True)),
163
+ Memory(
164
+ title="Love",
165
+ content="Cloud 9 love",
166
+ emotional=EmotionalSnapshot(intensity=10.0, cloud9_achieved=True),
167
+ ),
162
168
  Memory(title="Bug", content="Fixed deploy code", role=MemoryRole.DEV),
163
169
  ]
164
170
  soul_only = filter_by_quadrant(memories, Quadrant.SOUL)