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.
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