superlocalmemory 2.6.5 → 2.7.1

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.
@@ -0,0 +1,295 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ SuperLocalMemory V2 - Tests for FeedbackCollector (v2.7)
4
+ Copyright (c) 2026 Varun Pratap Bhardwaj
5
+ Licensed under MIT License
6
+ """
7
+
8
+ import hashlib
9
+ import time
10
+
11
+ import pytest
12
+
13
+
14
+ # ---------------------------------------------------------------------------
15
+ # Fixtures
16
+ # ---------------------------------------------------------------------------
17
+
18
+ @pytest.fixture(autouse=True)
19
+ def reset_singleton():
20
+ from src.learning.learning_db import LearningDB
21
+ LearningDB.reset_instance()
22
+ yield
23
+ LearningDB.reset_instance()
24
+
25
+
26
+ @pytest.fixture
27
+ def learning_db(tmp_path):
28
+ from src.learning.learning_db import LearningDB
29
+ db_path = tmp_path / "learning.db"
30
+ return LearningDB(db_path=db_path)
31
+
32
+
33
+ @pytest.fixture
34
+ def collector(learning_db):
35
+ from src.learning.feedback_collector import FeedbackCollector
36
+ return FeedbackCollector(learning_db=learning_db)
37
+
38
+
39
+ @pytest.fixture
40
+ def collector_no_db():
41
+ """Collector with no database — tests graceful degradation."""
42
+ from src.learning.feedback_collector import FeedbackCollector
43
+ return FeedbackCollector(learning_db=None)
44
+
45
+
46
+ # ---------------------------------------------------------------------------
47
+ # Channel 1: MCP memory_used
48
+ # ---------------------------------------------------------------------------
49
+
50
+ class TestRecordMemoryUsed:
51
+ def test_high_usefulness(self, collector, learning_db):
52
+ row_id = collector.record_memory_used(42, "deploy fastapi", usefulness="high")
53
+ assert row_id is not None
54
+
55
+ rows = learning_db.get_feedback_for_training()
56
+ assert len(rows) == 1
57
+ assert rows[0]["signal_type"] == "mcp_used_high"
58
+ assert rows[0]["signal_value"] == 1.0
59
+ assert rows[0]["channel"] == "mcp"
60
+
61
+ def test_medium_usefulness(self, collector, learning_db):
62
+ collector.record_memory_used(42, "deploy fastapi", usefulness="medium")
63
+ rows = learning_db.get_feedback_for_training()
64
+ assert rows[0]["signal_type"] == "mcp_used_medium"
65
+ assert rows[0]["signal_value"] == 0.7
66
+
67
+ def test_low_usefulness(self, collector, learning_db):
68
+ collector.record_memory_used(42, "deploy fastapi", usefulness="low")
69
+ rows = learning_db.get_feedback_for_training()
70
+ assert rows[0]["signal_type"] == "mcp_used_low"
71
+ assert rows[0]["signal_value"] == 0.4
72
+
73
+ def test_invalid_usefulness_defaults_to_high(self, collector, learning_db):
74
+ collector.record_memory_used(42, "test query", usefulness="INVALID")
75
+ rows = learning_db.get_feedback_for_training()
76
+ assert rows[0]["signal_type"] == "mcp_used_high"
77
+
78
+ def test_empty_query_returns_none(self, collector):
79
+ result = collector.record_memory_used(42, "")
80
+ assert result is None
81
+
82
+ def test_source_tool_recorded(self, collector, learning_db):
83
+ collector.record_memory_used(
84
+ 42, "test query", source_tool="claude-desktop", rank_position=2,
85
+ )
86
+ rows = learning_db.get_feedback_for_training()
87
+ assert rows[0]["source_tool"] == "claude-desktop"
88
+ assert rows[0]["rank_position"] == 2
89
+
90
+ def test_no_db_returns_none(self, collector_no_db):
91
+ result = collector_no_db.record_memory_used(42, "test query")
92
+ assert result is None
93
+
94
+
95
+ # ---------------------------------------------------------------------------
96
+ # Channel 2: CLI slm useful
97
+ # ---------------------------------------------------------------------------
98
+
99
+ class TestRecordCliUseful:
100
+ def test_batch_ids(self, collector, learning_db):
101
+ row_ids = collector.record_cli_useful([10, 20, 30], "deploy fastapi")
102
+ assert len(row_ids) == 3
103
+ assert all(rid is not None for rid in row_ids)
104
+ assert learning_db.get_feedback_count() == 3
105
+
106
+ def test_signal_value(self, collector, learning_db):
107
+ collector.record_cli_useful([42], "query")
108
+ rows = learning_db.get_feedback_for_training()
109
+ assert rows[0]["signal_value"] == 0.9
110
+ assert rows[0]["signal_type"] == "cli_useful"
111
+ assert rows[0]["channel"] == "cli"
112
+
113
+ def test_all_share_same_query_hash(self, collector, learning_db):
114
+ collector.record_cli_useful([1, 2, 3], "same query")
115
+ rows = learning_db.get_feedback_for_training()
116
+ hashes = {r["query_hash"] for r in rows}
117
+ assert len(hashes) == 1
118
+
119
+ def test_empty_query(self, collector):
120
+ result = collector.record_cli_useful([1, 2], "")
121
+ assert result == [None, None]
122
+
123
+
124
+ # ---------------------------------------------------------------------------
125
+ # Channel 3: Dashboard click
126
+ # ---------------------------------------------------------------------------
127
+
128
+ class TestRecordDashboardClick:
129
+ def test_basic_click(self, collector, learning_db):
130
+ row_id = collector.record_dashboard_click(42, "test query")
131
+ assert row_id is not None
132
+ rows = learning_db.get_feedback_for_training()
133
+ assert rows[0]["signal_type"] == "dashboard_click"
134
+ assert rows[0]["signal_value"] == 0.8
135
+ assert rows[0]["channel"] == "dashboard"
136
+
137
+ def test_with_dwell_time(self, collector, learning_db):
138
+ collector.record_dashboard_click(42, "test query", dwell_time=15.3)
139
+ # dwell_time is stored in ranking_feedback but not in training export
140
+ # Verify via direct DB query
141
+ conn = learning_db._get_connection()
142
+ cursor = conn.cursor()
143
+ cursor.execute("SELECT dwell_time FROM ranking_feedback WHERE memory_id = 42")
144
+ row = cursor.fetchone()
145
+ conn.close()
146
+ assert row is not None
147
+ assert abs(row[0] - 15.3) < 0.01
148
+
149
+ def test_empty_query_returns_none(self, collector):
150
+ result = collector.record_dashboard_click(42, "")
151
+ assert result is None
152
+
153
+
154
+ # ---------------------------------------------------------------------------
155
+ # Query Hashing
156
+ # ---------------------------------------------------------------------------
157
+
158
+ class TestHashQuery:
159
+ def test_deterministic(self, collector):
160
+ h1 = collector._hash_query("deploy fastapi")
161
+ h2 = collector._hash_query("deploy fastapi")
162
+ assert h1 == h2
163
+
164
+ def test_sha256_first_16(self, collector):
165
+ query = "deploy fastapi"
166
+ expected = hashlib.sha256(query.encode("utf-8")).hexdigest()[:16]
167
+ assert collector._hash_query(query) == expected
168
+
169
+ def test_length_is_16(self, collector):
170
+ result = collector._hash_query("any string")
171
+ assert len(result) == 16
172
+
173
+ def test_different_queries_different_hashes(self, collector):
174
+ h1 = collector._hash_query("query one")
175
+ h2 = collector._hash_query("query two")
176
+ assert h1 != h2
177
+
178
+
179
+ # ---------------------------------------------------------------------------
180
+ # Keyword Extraction
181
+ # ---------------------------------------------------------------------------
182
+
183
+ class TestExtractKeywords:
184
+ def test_stopword_removal(self, collector):
185
+ kw = collector._extract_keywords("how to deploy the app")
186
+ assert "how" not in kw
187
+ assert "to" not in kw
188
+ assert "the" not in kw
189
+ assert "deploy" in kw
190
+
191
+ def test_top_n_limit(self, collector):
192
+ kw = collector._extract_keywords(
193
+ "python fastapi docker kubernetes deployment pipeline"
194
+ )
195
+ assert len(kw.split(",")) <= 3
196
+
197
+ def test_empty_query(self, collector):
198
+ assert collector._extract_keywords("") == ""
199
+
200
+ def test_only_stopwords(self, collector):
201
+ assert collector._extract_keywords("the and or but") == ""
202
+
203
+ def test_comma_separated_output(self, collector):
204
+ kw = collector._extract_keywords("deploy fastapi docker")
205
+ assert "," in kw or len(kw.split(",")) == 1 # single keyword = no comma
206
+
207
+
208
+ # ---------------------------------------------------------------------------
209
+ # Passive Decay
210
+ # ---------------------------------------------------------------------------
211
+
212
+ class TestPassiveDecay:
213
+ def test_record_recall_results(self, collector):
214
+ collector.record_recall_results("test query", [1, 2, 3])
215
+ assert collector._recall_count == 1
216
+
217
+ def test_compute_passive_decay_below_threshold(self, collector):
218
+ """Should return 0 if below threshold."""
219
+ collector.record_recall_results("test query", [1, 2, 3])
220
+ result = collector.compute_passive_decay(threshold=10)
221
+ assert result == 0
222
+
223
+ def test_compute_passive_decay_with_candidates(self, collector, learning_db):
224
+ """Memory appearing in 5+ distinct queries should get decay signal."""
225
+ # Create 10+ recall operations with memory_id=99 appearing in 6 distinct queries
226
+ for i in range(10):
227
+ collector.record_recall_results(f"query_{i}", [99, 100 + i])
228
+
229
+ decay_count = collector.compute_passive_decay(threshold=10)
230
+ # memory 99 appeared in 10 distinct queries, no positive feedback
231
+ assert decay_count >= 1
232
+
233
+ def test_no_decay_for_positively_rated(self, collector, learning_db):
234
+ """Memories with positive feedback should NOT get passive decay."""
235
+ # Give memory 99 positive feedback first
236
+ learning_db.store_feedback(
237
+ query_hash="q", memory_id=99, signal_type="mcp_used",
238
+ signal_value=1.0, channel="mcp",
239
+ )
240
+
241
+ # Record 10+ recall operations with memory 99
242
+ for i in range(12):
243
+ collector.record_recall_results(f"query_{i}", [99])
244
+
245
+ decay_count = collector.compute_passive_decay(threshold=10)
246
+ assert decay_count == 0
247
+
248
+ def test_buffer_cleared_after_decay(self, collector):
249
+ """Recall buffer should be cleared after computing decay."""
250
+ for i in range(10):
251
+ collector.record_recall_results(f"q{i}", [1])
252
+ collector.compute_passive_decay(threshold=10)
253
+ assert collector._recall_count == 0
254
+
255
+ def test_empty_query_ignored(self, collector):
256
+ collector.record_recall_results("", [1, 2, 3])
257
+ assert collector._recall_count == 0
258
+
259
+ def test_empty_ids_ignored(self, collector):
260
+ collector.record_recall_results("test query", [])
261
+ assert collector._recall_count == 0
262
+
263
+
264
+ # ---------------------------------------------------------------------------
265
+ # Summary
266
+ # ---------------------------------------------------------------------------
267
+
268
+ class TestFeedbackSummary:
269
+ def test_summary_with_data(self, collector, learning_db):
270
+ collector.record_memory_used(1, "q1", usefulness="high")
271
+ collector.record_cli_useful([2], "q2")
272
+ collector.record_dashboard_click(3, "q3")
273
+
274
+ summary = collector.get_feedback_summary()
275
+ assert summary["total_signals"] == 3
276
+ assert summary["unique_queries"] == 3
277
+ assert "mcp" in summary["by_channel"]
278
+ assert "cli" in summary["by_channel"]
279
+ assert "dashboard" in summary["by_channel"]
280
+
281
+ def test_summary_empty_db(self, collector):
282
+ summary = collector.get_feedback_summary()
283
+ assert summary["total_signals"] == 0
284
+ assert summary["unique_queries"] == 0
285
+
286
+ def test_summary_no_db(self, collector_no_db):
287
+ summary = collector_no_db.get_feedback_summary()
288
+ assert "error" in summary
289
+
290
+ def test_summary_buffer_stats(self, collector):
291
+ collector.record_recall_results("q1", [1, 2, 3])
292
+ collector.record_recall_results("q2", [1, 4, 5])
293
+
294
+ summary = collector.get_feedback_summary()
295
+ assert summary["recall_buffer_size"] == 2