superlocalmemory 2.6.5 → 2.7.0
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/CHANGELOG.md +48 -0
- package/README.md +96 -13
- package/bin/slm +179 -3
- package/bin/superlocalmemoryv2:learning +4 -0
- package/bin/superlocalmemoryv2:patterns +4 -0
- package/docs/ARCHITECTURE.md +12 -6
- package/docs/MCP-MANUAL-SETUP.md +14 -4
- package/install.sh +99 -3
- package/mcp_server.py +291 -1
- package/package.json +2 -1
- package/requirements-learning.txt +12 -0
- package/scripts/verify-v27.sh +233 -0
- package/skills/slm-show-patterns/SKILL.md +224 -0
- package/src/learning/synthetic_bootstrap.py +1047 -0
- package/src/learning/tests/__init__.py +0 -0
- package/src/learning/tests/test_adaptive_ranker.py +328 -0
- package/src/learning/tests/test_aggregator.py +309 -0
- package/src/learning/tests/test_feedback_collector.py +295 -0
- package/src/learning/tests/test_learning_db.py +606 -0
- package/src/learning/tests/test_project_context.py +296 -0
- package/src/learning/tests/test_source_quality.py +355 -0
- package/src/learning/tests/test_synthetic_bootstrap.py +433 -0
- package/src/learning/tests/test_workflow_miner.py +322 -0
|
File without changes
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
SuperLocalMemory V2 - Tests for AdaptiveRanker (v2.7)
|
|
4
|
+
Copyright (c) 2026 Varun Pratap Bhardwaj
|
|
5
|
+
Licensed under MIT License
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import pytest
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
# Detect optional dependencies at import time
|
|
12
|
+
try:
|
|
13
|
+
import lightgbm
|
|
14
|
+
HAS_LIGHTGBM = True
|
|
15
|
+
except ImportError:
|
|
16
|
+
HAS_LIGHTGBM = False
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
import numpy as np
|
|
20
|
+
HAS_NUMPY = True
|
|
21
|
+
except ImportError:
|
|
22
|
+
np = None
|
|
23
|
+
HAS_NUMPY = False
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
# Fixtures
|
|
28
|
+
# ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
@pytest.fixture(autouse=True)
|
|
31
|
+
def reset_singleton():
|
|
32
|
+
from src.learning.learning_db import LearningDB
|
|
33
|
+
LearningDB.reset_instance()
|
|
34
|
+
yield
|
|
35
|
+
LearningDB.reset_instance()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@pytest.fixture
|
|
39
|
+
def learning_db(tmp_path):
|
|
40
|
+
from src.learning.learning_db import LearningDB
|
|
41
|
+
db_path = tmp_path / "learning.db"
|
|
42
|
+
return LearningDB(db_path=db_path)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@pytest.fixture
|
|
46
|
+
def ranker(learning_db):
|
|
47
|
+
from src.learning.adaptive_ranker import AdaptiveRanker
|
|
48
|
+
return AdaptiveRanker(learning_db=learning_db)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _make_result(memory_id, score=0.5, content="test memory", importance=5,
|
|
52
|
+
project_name=None, created_at="2026-02-16 10:00:00",
|
|
53
|
+
access_count=0, match_type="keyword"):
|
|
54
|
+
"""Helper to build a search result dict."""
|
|
55
|
+
return {
|
|
56
|
+
"id": memory_id,
|
|
57
|
+
"content": content,
|
|
58
|
+
"score": score,
|
|
59
|
+
"match_type": match_type,
|
|
60
|
+
"importance": importance,
|
|
61
|
+
"created_at": created_at,
|
|
62
|
+
"access_count": access_count,
|
|
63
|
+
"project_name": project_name,
|
|
64
|
+
"tags": [],
|
|
65
|
+
"created_by": None,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ---------------------------------------------------------------------------
|
|
70
|
+
# Phase Detection
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
|
|
73
|
+
class TestGetPhase:
|
|
74
|
+
def test_baseline_with_zero_signals(self, ranker):
|
|
75
|
+
assert ranker.get_phase() == "baseline"
|
|
76
|
+
|
|
77
|
+
def test_baseline_with_few_signals(self, ranker, learning_db):
|
|
78
|
+
"""Less than 20 signals should stay in baseline."""
|
|
79
|
+
for i in range(10):
|
|
80
|
+
learning_db.store_feedback(
|
|
81
|
+
query_hash=f"q{i}",
|
|
82
|
+
memory_id=i,
|
|
83
|
+
signal_type="mcp_used",
|
|
84
|
+
)
|
|
85
|
+
assert ranker.get_phase() == "baseline"
|
|
86
|
+
|
|
87
|
+
def test_rule_based_at_20_signals(self, ranker, learning_db):
|
|
88
|
+
"""20+ signals should enter rule_based phase."""
|
|
89
|
+
for i in range(25):
|
|
90
|
+
learning_db.store_feedback(
|
|
91
|
+
query_hash=f"q{i}",
|
|
92
|
+
memory_id=i,
|
|
93
|
+
signal_type="mcp_used",
|
|
94
|
+
)
|
|
95
|
+
assert ranker.get_phase() == "rule_based"
|
|
96
|
+
|
|
97
|
+
@pytest.mark.skipif(not HAS_LIGHTGBM or not HAS_NUMPY,
|
|
98
|
+
reason="LightGBM/NumPy required for ML phase")
|
|
99
|
+
def test_ml_model_at_200_signals(self, ranker, learning_db):
|
|
100
|
+
"""200+ signals across 50+ queries should trigger ml_model."""
|
|
101
|
+
for i in range(250):
|
|
102
|
+
learning_db.store_feedback(
|
|
103
|
+
query_hash=f"q{i % 60}", # 60 unique queries
|
|
104
|
+
memory_id=i,
|
|
105
|
+
signal_type="mcp_used",
|
|
106
|
+
)
|
|
107
|
+
assert ranker.get_phase() == "ml_model"
|
|
108
|
+
|
|
109
|
+
def test_ml_model_requires_enough_unique_queries(self, ranker, learning_db):
|
|
110
|
+
"""200+ signals but only 10 unique queries should stay rule_based."""
|
|
111
|
+
for i in range(250):
|
|
112
|
+
learning_db.store_feedback(
|
|
113
|
+
query_hash=f"q{i % 10}", # Only 10 unique queries
|
|
114
|
+
memory_id=i,
|
|
115
|
+
signal_type="mcp_used",
|
|
116
|
+
)
|
|
117
|
+
# Even with LightGBM available, not enough unique queries
|
|
118
|
+
phase = ranker.get_phase()
|
|
119
|
+
assert phase in ("rule_based", "ml_model")
|
|
120
|
+
if HAS_LIGHTGBM and HAS_NUMPY:
|
|
121
|
+
assert phase == "rule_based" # 10 < 50 unique queries
|
|
122
|
+
|
|
123
|
+
def test_no_learning_db_returns_baseline(self):
|
|
124
|
+
from src.learning.adaptive_ranker import AdaptiveRanker
|
|
125
|
+
ranker = AdaptiveRanker(learning_db=None)
|
|
126
|
+
# Force no lazy init
|
|
127
|
+
ranker._learning_db = None
|
|
128
|
+
assert ranker.get_phase() == "baseline"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# ---------------------------------------------------------------------------
|
|
132
|
+
# Phase Info
|
|
133
|
+
# ---------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
class TestGetPhaseInfo:
|
|
136
|
+
def test_phase_info_structure(self, ranker):
|
|
137
|
+
info = ranker.get_phase_info()
|
|
138
|
+
assert "phase" in info
|
|
139
|
+
assert "feedback_count" in info
|
|
140
|
+
assert "unique_queries" in info
|
|
141
|
+
assert "thresholds" in info
|
|
142
|
+
assert "model_loaded" in info
|
|
143
|
+
assert "lightgbm_available" in info
|
|
144
|
+
assert "numpy_available" in info
|
|
145
|
+
|
|
146
|
+
def test_phase_info_values(self, ranker):
|
|
147
|
+
info = ranker.get_phase_info()
|
|
148
|
+
assert info["phase"] == "baseline"
|
|
149
|
+
assert info["feedback_count"] == 0
|
|
150
|
+
assert info["unique_queries"] == 0
|
|
151
|
+
assert info["model_loaded"] is False
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# ---------------------------------------------------------------------------
|
|
155
|
+
# Rerank Routing
|
|
156
|
+
# ---------------------------------------------------------------------------
|
|
157
|
+
|
|
158
|
+
class TestRerank:
|
|
159
|
+
def test_empty_results(self, ranker):
|
|
160
|
+
result = ranker.rerank([], "query")
|
|
161
|
+
assert result == []
|
|
162
|
+
|
|
163
|
+
def test_single_result_baseline(self, ranker):
|
|
164
|
+
"""Single result should get baseline phase annotation."""
|
|
165
|
+
results = [_make_result(1, score=0.8)]
|
|
166
|
+
reranked = ranker.rerank(results, "test query")
|
|
167
|
+
assert len(reranked) == 1
|
|
168
|
+
assert reranked[0]["ranking_phase"] == "baseline"
|
|
169
|
+
assert reranked[0]["base_score"] == 0.8
|
|
170
|
+
|
|
171
|
+
def test_baseline_preserves_order(self, ranker):
|
|
172
|
+
"""In baseline phase, original order should be preserved."""
|
|
173
|
+
results = [
|
|
174
|
+
_make_result(1, score=0.9),
|
|
175
|
+
_make_result(2, score=0.5),
|
|
176
|
+
_make_result(3, score=0.3),
|
|
177
|
+
]
|
|
178
|
+
reranked = ranker.rerank(results, "test query")
|
|
179
|
+
# All should be baseline
|
|
180
|
+
for r in reranked:
|
|
181
|
+
assert r["ranking_phase"] == "baseline"
|
|
182
|
+
# Order preserved (no re-sorting in baseline)
|
|
183
|
+
assert reranked[0]["id"] == 1
|
|
184
|
+
assert reranked[1]["id"] == 2
|
|
185
|
+
assert reranked[2]["id"] == 3
|
|
186
|
+
|
|
187
|
+
def test_base_score_preserved(self, ranker, learning_db):
|
|
188
|
+
"""base_score should always contain the original score."""
|
|
189
|
+
# Add enough feedback for rule_based
|
|
190
|
+
for i in range(25):
|
|
191
|
+
learning_db.store_feedback(
|
|
192
|
+
query_hash=f"q{i}", memory_id=i, signal_type="mcp_used",
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
results = [
|
|
196
|
+
_make_result(1, score=0.8),
|
|
197
|
+
_make_result(2, score=0.5),
|
|
198
|
+
]
|
|
199
|
+
reranked = ranker.rerank(results, "test query")
|
|
200
|
+
for r in reranked:
|
|
201
|
+
assert "base_score" in r
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# ---------------------------------------------------------------------------
|
|
205
|
+
# Rule-Based Re-ranking
|
|
206
|
+
# ---------------------------------------------------------------------------
|
|
207
|
+
|
|
208
|
+
class TestRuleBasedReranking:
|
|
209
|
+
def test_boost_applied(self, ranker, learning_db):
|
|
210
|
+
"""Rule-based should modify scores based on features."""
|
|
211
|
+
for i in range(25):
|
|
212
|
+
learning_db.store_feedback(
|
|
213
|
+
query_hash=f"q{i}", memory_id=i, signal_type="mcp_used",
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
results = [
|
|
217
|
+
_make_result(1, score=0.5, importance=9, access_count=8),
|
|
218
|
+
_make_result(2, score=0.5, importance=2, access_count=0),
|
|
219
|
+
]
|
|
220
|
+
reranked = ranker.rerank(results, "test query")
|
|
221
|
+
|
|
222
|
+
# Both should be rule_based
|
|
223
|
+
assert all(r["ranking_phase"] == "rule_based" for r in reranked)
|
|
224
|
+
|
|
225
|
+
# High importance + access should get higher score
|
|
226
|
+
high_imp = next(r for r in reranked if r["id"] == 1)
|
|
227
|
+
low_imp = next(r for r in reranked if r["id"] == 2)
|
|
228
|
+
assert high_imp["score"] > low_imp["score"]
|
|
229
|
+
|
|
230
|
+
def test_project_match_boost(self, ranker, learning_db):
|
|
231
|
+
"""Memory matching current project should be boosted."""
|
|
232
|
+
for i in range(25):
|
|
233
|
+
learning_db.store_feedback(
|
|
234
|
+
query_hash=f"q{i}", memory_id=i, signal_type="mcp_used",
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
results = [
|
|
238
|
+
_make_result(1, score=0.5, project_name="SLM"),
|
|
239
|
+
_make_result(2, score=0.5, project_name="OTHER"),
|
|
240
|
+
]
|
|
241
|
+
context = {"current_project": "SLM"}
|
|
242
|
+
reranked = ranker.rerank(results, "test query", context=context)
|
|
243
|
+
|
|
244
|
+
slm_result = next(r for r in reranked if r["id"] == 1)
|
|
245
|
+
other_result = next(r for r in reranked if r["id"] == 2)
|
|
246
|
+
assert slm_result["score"] > other_result["score"]
|
|
247
|
+
|
|
248
|
+
def test_results_resorted(self, ranker, learning_db):
|
|
249
|
+
"""Results should be re-sorted by boosted score."""
|
|
250
|
+
for i in range(25):
|
|
251
|
+
learning_db.store_feedback(
|
|
252
|
+
query_hash=f"q{i}", memory_id=i, signal_type="mcp_used",
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
# Second result has much higher importance
|
|
256
|
+
results = [
|
|
257
|
+
_make_result(1, score=0.5, importance=2),
|
|
258
|
+
_make_result(2, score=0.5, importance=10, access_count=10),
|
|
259
|
+
]
|
|
260
|
+
reranked = ranker.rerank(results, "test query")
|
|
261
|
+
# Higher importance should float to top
|
|
262
|
+
assert reranked[0]["id"] == 2
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
# ---------------------------------------------------------------------------
|
|
266
|
+
# ML Training (skipped if LightGBM not available)
|
|
267
|
+
# ---------------------------------------------------------------------------
|
|
268
|
+
|
|
269
|
+
class TestTraining:
|
|
270
|
+
@pytest.mark.skipif(not HAS_LIGHTGBM or not HAS_NUMPY,
|
|
271
|
+
reason="LightGBM/NumPy required")
|
|
272
|
+
def test_train_insufficient_data(self, ranker, learning_db):
|
|
273
|
+
"""Training should return None with insufficient data."""
|
|
274
|
+
result = ranker.train()
|
|
275
|
+
assert result is None
|
|
276
|
+
|
|
277
|
+
def test_train_without_lightgbm(self, ranker):
|
|
278
|
+
"""Should gracefully handle missing LightGBM."""
|
|
279
|
+
from src.learning import adaptive_ranker as ar_module
|
|
280
|
+
original = ar_module.HAS_LIGHTGBM
|
|
281
|
+
ar_module.HAS_LIGHTGBM = False
|
|
282
|
+
try:
|
|
283
|
+
result = ranker.train()
|
|
284
|
+
assert result is None
|
|
285
|
+
finally:
|
|
286
|
+
ar_module.HAS_LIGHTGBM = original
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
# ---------------------------------------------------------------------------
|
|
290
|
+
# Model Loading Fallback
|
|
291
|
+
# ---------------------------------------------------------------------------
|
|
292
|
+
|
|
293
|
+
class TestModelLoading:
|
|
294
|
+
def test_load_nonexistent_model(self, ranker):
|
|
295
|
+
"""Loading a model that doesn't exist should return None."""
|
|
296
|
+
model = ranker._load_model()
|
|
297
|
+
assert model is None
|
|
298
|
+
|
|
299
|
+
def test_load_attempt_cached(self, ranker):
|
|
300
|
+
"""After first failed load, _model_load_attempted should be True."""
|
|
301
|
+
ranker._load_model()
|
|
302
|
+
assert ranker._model_load_attempted is True
|
|
303
|
+
|
|
304
|
+
def test_second_load_returns_cached_none(self, ranker):
|
|
305
|
+
"""Second load attempt should return None immediately (cached failure)."""
|
|
306
|
+
ranker._load_model()
|
|
307
|
+
result = ranker._load_model()
|
|
308
|
+
assert result is None
|
|
309
|
+
|
|
310
|
+
def test_reload_model_resets_flag(self, ranker):
|
|
311
|
+
"""reload_model should reset the _model_load_attempted flag."""
|
|
312
|
+
ranker._load_model()
|
|
313
|
+
assert ranker._model_load_attempted is True
|
|
314
|
+
ranker.reload_model()
|
|
315
|
+
# After reload, the flag should have been reset and tried again
|
|
316
|
+
# (and failed again since no model file exists)
|
|
317
|
+
assert ranker._model is None
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
# ---------------------------------------------------------------------------
|
|
321
|
+
# Module-level convenience
|
|
322
|
+
# ---------------------------------------------------------------------------
|
|
323
|
+
|
|
324
|
+
class TestModuleLevel:
|
|
325
|
+
def test_get_phase_function(self):
|
|
326
|
+
from src.learning.adaptive_ranker import get_phase
|
|
327
|
+
phase = get_phase()
|
|
328
|
+
assert phase in ("baseline", "rule_based", "ml_model")
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
SuperLocalMemory V2 - Tests for CrossProjectAggregator (v2.7)
|
|
4
|
+
Copyright (c) 2026 Varun Pratap Bhardwaj
|
|
5
|
+
Licensed under MIT License
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import math
|
|
9
|
+
import sqlite3
|
|
10
|
+
from datetime import datetime, timedelta
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from unittest.mock import patch, MagicMock
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# ---------------------------------------------------------------------------
|
|
18
|
+
# Fixtures
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
@pytest.fixture(autouse=True)
|
|
22
|
+
def reset_singleton():
|
|
23
|
+
from src.learning.learning_db import LearningDB
|
|
24
|
+
LearningDB.reset_instance()
|
|
25
|
+
yield
|
|
26
|
+
LearningDB.reset_instance()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@pytest.fixture
|
|
30
|
+
def learning_db(tmp_path):
|
|
31
|
+
from src.learning.learning_db import LearningDB
|
|
32
|
+
db_path = tmp_path / "learning.db"
|
|
33
|
+
return LearningDB(db_path=db_path)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@pytest.fixture
|
|
37
|
+
def memory_db(tmp_path):
|
|
38
|
+
"""Create a minimal memory.db with test data."""
|
|
39
|
+
db_path = tmp_path / "memory.db"
|
|
40
|
+
conn = sqlite3.connect(str(db_path))
|
|
41
|
+
cursor = conn.cursor()
|
|
42
|
+
cursor.execute('''
|
|
43
|
+
CREATE TABLE IF NOT EXISTS memories (
|
|
44
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
45
|
+
content TEXT NOT NULL,
|
|
46
|
+
summary TEXT,
|
|
47
|
+
project_path TEXT,
|
|
48
|
+
project_name TEXT,
|
|
49
|
+
tags TEXT DEFAULT '[]',
|
|
50
|
+
category TEXT,
|
|
51
|
+
parent_id INTEGER,
|
|
52
|
+
tree_path TEXT DEFAULT '/',
|
|
53
|
+
depth INTEGER DEFAULT 0,
|
|
54
|
+
memory_type TEXT DEFAULT 'session',
|
|
55
|
+
importance INTEGER DEFAULT 5,
|
|
56
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
|
57
|
+
last_accessed TIMESTAMP,
|
|
58
|
+
access_count INTEGER DEFAULT 0,
|
|
59
|
+
content_hash TEXT,
|
|
60
|
+
cluster_id INTEGER,
|
|
61
|
+
profile TEXT DEFAULT 'default',
|
|
62
|
+
created_by TEXT,
|
|
63
|
+
source_protocol TEXT,
|
|
64
|
+
trust_score REAL DEFAULT 1.0
|
|
65
|
+
)
|
|
66
|
+
''')
|
|
67
|
+
conn.commit()
|
|
68
|
+
conn.close()
|
|
69
|
+
return db_path
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _insert_memories(db_path, memories):
|
|
73
|
+
conn = sqlite3.connect(str(db_path))
|
|
74
|
+
cursor = conn.cursor()
|
|
75
|
+
for m in memories:
|
|
76
|
+
cursor.execute('''
|
|
77
|
+
INSERT INTO memories (content, tags, project_name, project_path,
|
|
78
|
+
importance, access_count, profile, created_by,
|
|
79
|
+
source_protocol, created_at)
|
|
80
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
81
|
+
''', (
|
|
82
|
+
m.get('content', 'test'),
|
|
83
|
+
m.get('tags', '[]'),
|
|
84
|
+
m.get('project_name'),
|
|
85
|
+
m.get('project_path'),
|
|
86
|
+
m.get('importance', 5),
|
|
87
|
+
m.get('access_count', 0),
|
|
88
|
+
m.get('profile', 'default'),
|
|
89
|
+
m.get('created_by'),
|
|
90
|
+
m.get('source_protocol'),
|
|
91
|
+
m.get('created_at', '2026-02-16 10:00:00'),
|
|
92
|
+
))
|
|
93
|
+
conn.commit()
|
|
94
|
+
conn.close()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
# Temporal Decay
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
class TestTemporalDecay:
|
|
102
|
+
def test_days_since_recent(self):
|
|
103
|
+
from src.learning.cross_project_aggregator import CrossProjectAggregator
|
|
104
|
+
now = datetime(2026, 2, 16, 12, 0, 0)
|
|
105
|
+
ts = "2026-02-16T10:00:00"
|
|
106
|
+
days = CrossProjectAggregator._days_since(ts, now)
|
|
107
|
+
assert 0.0 <= days < 1.0
|
|
108
|
+
|
|
109
|
+
def test_days_since_365_days_ago(self):
|
|
110
|
+
from src.learning.cross_project_aggregator import CrossProjectAggregator
|
|
111
|
+
now = datetime(2026, 2, 16, 12, 0, 0)
|
|
112
|
+
old = (now - timedelta(days=365)).isoformat()
|
|
113
|
+
days = CrossProjectAggregator._days_since(old, now)
|
|
114
|
+
assert abs(days - 365.0) < 1.0
|
|
115
|
+
|
|
116
|
+
def test_days_since_empty_string(self):
|
|
117
|
+
from src.learning.cross_project_aggregator import CrossProjectAggregator
|
|
118
|
+
assert CrossProjectAggregator._days_since("") == 0.0
|
|
119
|
+
|
|
120
|
+
def test_days_since_invalid_string(self):
|
|
121
|
+
from src.learning.cross_project_aggregator import CrossProjectAggregator
|
|
122
|
+
assert CrossProjectAggregator._days_since("not-a-date") == 0.0
|
|
123
|
+
|
|
124
|
+
def test_days_since_space_separated(self):
|
|
125
|
+
from src.learning.cross_project_aggregator import CrossProjectAggregator
|
|
126
|
+
now = datetime(2026, 2, 16, 12, 0, 0)
|
|
127
|
+
ts = "2026-02-15 12:00:00"
|
|
128
|
+
days = CrossProjectAggregator._days_since(ts, now)
|
|
129
|
+
assert abs(days - 1.0) < 0.01
|
|
130
|
+
|
|
131
|
+
def test_decay_weight_recent(self):
|
|
132
|
+
"""Recent timestamp -> weight close to 1.0."""
|
|
133
|
+
from src.learning.cross_project_aggregator import DECAY_HALF_LIFE_DAYS
|
|
134
|
+
# 0 days -> exp(0) = 1.0
|
|
135
|
+
weight = math.exp(-0.0 / DECAY_HALF_LIFE_DAYS)
|
|
136
|
+
assert abs(weight - 1.0) < 0.001
|
|
137
|
+
|
|
138
|
+
def test_decay_weight_365_days(self):
|
|
139
|
+
"""365-day-old pattern -> weight ~ 0.37."""
|
|
140
|
+
from src.learning.cross_project_aggregator import DECAY_HALF_LIFE_DAYS
|
|
141
|
+
weight = math.exp(-365.0 / DECAY_HALF_LIFE_DAYS)
|
|
142
|
+
assert 0.30 < weight < 0.40
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
# ---------------------------------------------------------------------------
|
|
146
|
+
# Contradiction Detection
|
|
147
|
+
# ---------------------------------------------------------------------------
|
|
148
|
+
|
|
149
|
+
class TestContradictionDetection:
|
|
150
|
+
def test_cross_profile_disagreement(self, learning_db, memory_db):
|
|
151
|
+
"""Two profiles with different values should trigger a contradiction."""
|
|
152
|
+
from src.learning.cross_project_aggregator import CrossProjectAggregator
|
|
153
|
+
|
|
154
|
+
aggregator = CrossProjectAggregator(
|
|
155
|
+
memory_db_path=memory_db,
|
|
156
|
+
learning_db=learning_db,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
pattern_data = {
|
|
160
|
+
"value": "react",
|
|
161
|
+
"profile_history": [
|
|
162
|
+
{"profile": "work", "value": "react", "confidence": 0.8,
|
|
163
|
+
"weight": 1.0, "timestamp": "2026-02-16"},
|
|
164
|
+
{"profile": "personal", "value": "vue", "confidence": 0.7,
|
|
165
|
+
"weight": 0.9, "timestamp": "2026-02-15"},
|
|
166
|
+
],
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
contradictions = aggregator._detect_contradictions("frontend", pattern_data)
|
|
170
|
+
assert len(contradictions) >= 1
|
|
171
|
+
assert any("vue" in c and "react" in c for c in contradictions)
|
|
172
|
+
|
|
173
|
+
def test_no_contradiction_when_unanimous(self, learning_db, memory_db):
|
|
174
|
+
from src.learning.cross_project_aggregator import CrossProjectAggregator
|
|
175
|
+
aggregator = CrossProjectAggregator(
|
|
176
|
+
memory_db_path=memory_db,
|
|
177
|
+
learning_db=learning_db,
|
|
178
|
+
)
|
|
179
|
+
pattern_data = {
|
|
180
|
+
"value": "python",
|
|
181
|
+
"profile_history": [
|
|
182
|
+
{"profile": "work", "value": "python", "confidence": 0.9,
|
|
183
|
+
"weight": 1.0, "timestamp": "2026-02-16"},
|
|
184
|
+
{"profile": "personal", "value": "python", "confidence": 0.8,
|
|
185
|
+
"weight": 0.9, "timestamp": "2026-02-15"},
|
|
186
|
+
],
|
|
187
|
+
}
|
|
188
|
+
contradictions = aggregator._detect_contradictions("lang", pattern_data)
|
|
189
|
+
assert len(contradictions) == 0
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# ---------------------------------------------------------------------------
|
|
193
|
+
# get_tech_preferences from learning.db
|
|
194
|
+
# ---------------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
class TestGetTechPreferences:
|
|
197
|
+
def test_empty_db_returns_empty(self, learning_db, memory_db):
|
|
198
|
+
from src.learning.cross_project_aggregator import CrossProjectAggregator
|
|
199
|
+
aggregator = CrossProjectAggregator(
|
|
200
|
+
memory_db_path=memory_db,
|
|
201
|
+
learning_db=learning_db,
|
|
202
|
+
)
|
|
203
|
+
prefs = aggregator.get_tech_preferences(min_confidence=0.0)
|
|
204
|
+
assert prefs == {}
|
|
205
|
+
|
|
206
|
+
def test_stored_patterns_returned(self, learning_db, memory_db):
|
|
207
|
+
from src.learning.cross_project_aggregator import CrossProjectAggregator
|
|
208
|
+
|
|
209
|
+
# Pre-populate learning.db with a preference pattern
|
|
210
|
+
learning_db.upsert_transferable_pattern(
|
|
211
|
+
pattern_type="preference",
|
|
212
|
+
key="language",
|
|
213
|
+
value="python",
|
|
214
|
+
confidence=0.85,
|
|
215
|
+
evidence_count=15,
|
|
216
|
+
profiles_seen=2,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
aggregator = CrossProjectAggregator(
|
|
220
|
+
memory_db_path=memory_db,
|
|
221
|
+
learning_db=learning_db,
|
|
222
|
+
)
|
|
223
|
+
prefs = aggregator.get_tech_preferences(min_confidence=0.5)
|
|
224
|
+
assert "language" in prefs
|
|
225
|
+
assert prefs["language"]["value"] == "python"
|
|
226
|
+
assert prefs["language"]["confidence"] == 0.85
|
|
227
|
+
|
|
228
|
+
def test_confidence_filter(self, learning_db, memory_db):
|
|
229
|
+
from src.learning.cross_project_aggregator import CrossProjectAggregator
|
|
230
|
+
|
|
231
|
+
learning_db.upsert_transferable_pattern(
|
|
232
|
+
pattern_type="preference", key="low", value="x",
|
|
233
|
+
confidence=0.3, evidence_count=2,
|
|
234
|
+
)
|
|
235
|
+
learning_db.upsert_transferable_pattern(
|
|
236
|
+
pattern_type="preference", key="high", value="y",
|
|
237
|
+
confidence=0.9, evidence_count=20,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
aggregator = CrossProjectAggregator(
|
|
241
|
+
memory_db_path=memory_db,
|
|
242
|
+
learning_db=learning_db,
|
|
243
|
+
)
|
|
244
|
+
prefs = aggregator.get_tech_preferences(min_confidence=0.6)
|
|
245
|
+
assert "high" in prefs
|
|
246
|
+
assert "low" not in prefs
|
|
247
|
+
|
|
248
|
+
def test_no_learning_db(self, memory_db):
|
|
249
|
+
from src.learning.cross_project_aggregator import CrossProjectAggregator
|
|
250
|
+
aggregator = CrossProjectAggregator(
|
|
251
|
+
memory_db_path=memory_db,
|
|
252
|
+
learning_db=None,
|
|
253
|
+
)
|
|
254
|
+
# Should not crash
|
|
255
|
+
prefs = aggregator.get_tech_preferences()
|
|
256
|
+
assert prefs == {}
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
# ---------------------------------------------------------------------------
|
|
260
|
+
# is_within_window
|
|
261
|
+
# ---------------------------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
class TestIsWithinWindow:
|
|
264
|
+
def test_recent_within_window(self):
|
|
265
|
+
from src.learning.cross_project_aggregator import CrossProjectAggregator
|
|
266
|
+
now_str = datetime.now().isoformat()
|
|
267
|
+
assert CrossProjectAggregator._is_within_window(now_str, 90) is True
|
|
268
|
+
|
|
269
|
+
def test_old_outside_window(self):
|
|
270
|
+
from src.learning.cross_project_aggregator import CrossProjectAggregator
|
|
271
|
+
old = (datetime.now() - timedelta(days=200)).isoformat()
|
|
272
|
+
assert CrossProjectAggregator._is_within_window(old, 90) is False
|
|
273
|
+
|
|
274
|
+
def test_empty_timestamp(self):
|
|
275
|
+
from src.learning.cross_project_aggregator import CrossProjectAggregator
|
|
276
|
+
assert CrossProjectAggregator._is_within_window("", 90) is False
|
|
277
|
+
|
|
278
|
+
def test_invalid_timestamp(self):
|
|
279
|
+
from src.learning.cross_project_aggregator import CrossProjectAggregator
|
|
280
|
+
assert CrossProjectAggregator._is_within_window("not-a-date", 90) is False
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
# ---------------------------------------------------------------------------
|
|
284
|
+
# Preference Context Formatting
|
|
285
|
+
# ---------------------------------------------------------------------------
|
|
286
|
+
|
|
287
|
+
class TestPreferenceContext:
|
|
288
|
+
def test_no_preferences(self, learning_db, memory_db):
|
|
289
|
+
from src.learning.cross_project_aggregator import CrossProjectAggregator
|
|
290
|
+
aggregator = CrossProjectAggregator(
|
|
291
|
+
memory_db_path=memory_db,
|
|
292
|
+
learning_db=learning_db,
|
|
293
|
+
)
|
|
294
|
+
ctx = aggregator.get_preference_context()
|
|
295
|
+
assert "No transferable preferences learned yet" in ctx
|
|
296
|
+
|
|
297
|
+
def test_with_preferences(self, learning_db, memory_db):
|
|
298
|
+
from src.learning.cross_project_aggregator import CrossProjectAggregator
|
|
299
|
+
learning_db.upsert_transferable_pattern(
|
|
300
|
+
pattern_type="preference", key="framework", value="FastAPI",
|
|
301
|
+
confidence=0.8, evidence_count=10, profiles_seen=2,
|
|
302
|
+
)
|
|
303
|
+
aggregator = CrossProjectAggregator(
|
|
304
|
+
memory_db_path=memory_db,
|
|
305
|
+
learning_db=learning_db,
|
|
306
|
+
)
|
|
307
|
+
ctx = aggregator.get_preference_context(min_confidence=0.5)
|
|
308
|
+
assert "FastAPI" in ctx
|
|
309
|
+
assert "Framework" in ctx # Title-cased key
|