lex-cognitive-map 0.1.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.
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/cognitive_map/client'
4
+
5
+ RSpec.describe Legion::Extensions::CognitiveMap::Client do
6
+ subject(:client) { described_class.new }
7
+
8
+ it 'responds to all runner methods' do
9
+ expect(client).to respond_to(:add_location)
10
+ expect(client).to respond_to(:connect_locations)
11
+ expect(client).to respond_to(:visit_location)
12
+ expect(client).to respond_to(:find_path)
13
+ expect(client).to respond_to(:explore_neighborhood)
14
+ expect(client).to respond_to(:map_clusters)
15
+ expect(client).to respond_to(:familiar_locations)
16
+ expect(client).to respond_to(:switch_context)
17
+ expect(client).to respond_to(:update_cognitive_map)
18
+ expect(client).to respond_to(:cognitive_map_stats)
19
+ end
20
+
21
+ it 'accepts an injected map_store' do
22
+ custom_store = Legion::Extensions::CognitiveMap::Helpers::CognitiveMapStore.new
23
+ custom_store.add_location(id: 'pre_existing')
24
+ client2 = described_class.new(map_store: custom_store)
25
+ result = client2.cognitive_map_stats
26
+ expect(result[:location_count]).to eq(1)
27
+ end
28
+
29
+ describe 'integration: add and navigate' do
30
+ it 'adds locations, connects them, and finds a path' do
31
+ client.add_location(id: 'start', domain: :space)
32
+ client.add_location(id: 'mid', domain: :space)
33
+ client.add_location(id: 'end', domain: :space)
34
+ client.connect_locations(from: 'start', to: 'mid', distance: 1.0)
35
+ client.connect_locations(from: 'mid', to: 'end', distance: 1.0)
36
+
37
+ result = client.find_path(from: 'start', to: 'end')
38
+ expect(result[:success]).to be true
39
+ expect(result[:path]).to eq(%w[start mid end])
40
+ expect(result[:distance]).to eq(2.0)
41
+ end
42
+
43
+ it 'builds familiarity through visits' do
44
+ client.add_location(id: 'hub')
45
+ 5.times { client.visit_location(id: 'hub') }
46
+ familiar = client.familiar_locations(limit: 1)
47
+ expect(familiar[:locations].first[:id]).to eq('hub')
48
+ expect(familiar[:locations].first[:visit_count]).to eq(5)
49
+ end
50
+
51
+ it 'explores neighborhood up to a distance threshold' do
52
+ client.add_location(id: 'center')
53
+ client.add_location(id: 'near')
54
+ client.add_location(id: 'far')
55
+ client.connect_locations(from: 'center', to: 'near', distance: 1.0)
56
+ client.connect_locations(from: 'near', to: 'far', distance: 5.0)
57
+
58
+ result = client.explore_neighborhood(id: 'center', max_distance: 2.0)
59
+ ids = result[:reachable].map { |r| r[:id] }
60
+ expect(ids).to include('near')
61
+ expect(ids).not_to include('far')
62
+ end
63
+
64
+ it 'detects disconnected clusters' do
65
+ client.add_location(id: 'a')
66
+ client.add_location(id: 'b')
67
+ client.connect_locations(from: 'a', to: 'b')
68
+ client.add_location(id: 'island1')
69
+ client.add_location(id: 'island2')
70
+ client.connect_locations(from: 'island1', to: 'island2')
71
+
72
+ result = client.map_clusters
73
+ expect(result[:count]).to eq(2)
74
+ end
75
+
76
+ it 'isolates context maps from each other' do
77
+ client.add_location(id: 'shared_name', domain: :ctx_a)
78
+ client.switch_context(context_id: :ctx_b)
79
+ client.add_location(id: 'shared_name', domain: :ctx_b)
80
+
81
+ result_b = client.cognitive_map_stats
82
+ expect(result_b[:location_count]).to eq(1)
83
+
84
+ client.switch_context(context_id: :default)
85
+ result_default = client.cognitive_map_stats
86
+ expect(result_default[:location_count]).to eq(1)
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,321 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::CognitiveMap::Helpers::CognitiveMapStore do
4
+ subject(:store) { described_class.new }
5
+
6
+ def add_triangle
7
+ store.add_location(id: 'a')
8
+ store.add_location(id: 'b')
9
+ store.add_location(id: 'c')
10
+ store.connect(from: 'a', to: 'b', distance: 1.0)
11
+ store.connect(from: 'b', to: 'c', distance: 1.0)
12
+ store.connect(from: 'a', to: 'c', distance: 3.0)
13
+ end
14
+
15
+ describe '#add_location' do
16
+ it 'adds a location and returns true' do
17
+ expect(store.add_location(id: 'loc1')).to be true
18
+ end
19
+
20
+ it 'is idempotent for duplicate ids' do
21
+ store.add_location(id: 'loc1')
22
+ store.add_location(id: 'loc1', domain: :other)
23
+ expect(store.location_count).to eq(1)
24
+ end
25
+
26
+ it 'stores domain and properties' do
27
+ store.add_location(id: 'loc1', domain: :science, properties: { weight: 0.9 })
28
+ loc = store.location('loc1')
29
+ expect(loc.domain).to eq(:science)
30
+ expect(loc.properties[:weight]).to eq(0.9)
31
+ end
32
+
33
+ it 'enforces MAX_LOCATIONS' do
34
+ max = Legion::Extensions::CognitiveMap::Helpers::Constants::MAX_LOCATIONS
35
+ max.times { |i| store.add_location(id: "loc_#{i}") }
36
+ result = store.add_location(id: 'overflow')
37
+ expect(result).to be false
38
+ end
39
+ end
40
+
41
+ describe '#remove_location' do
42
+ it 'removes an existing location' do
43
+ store.add_location(id: 'loc1')
44
+ expect(store.remove_location('loc1')).to be true
45
+ expect(store.location('loc1')).to be_nil
46
+ end
47
+
48
+ it 'returns false for unknown location' do
49
+ expect(store.remove_location('nonexistent')).to be false
50
+ end
51
+
52
+ it 'removes edges pointing to removed location' do
53
+ store.add_location(id: 'a')
54
+ store.add_location(id: 'b')
55
+ store.connect(from: 'a', to: 'b')
56
+ store.remove_location('b')
57
+ expect(store.neighbors_of(id: 'a')).to be_empty
58
+ end
59
+ end
60
+
61
+ describe '#connect' do
62
+ before do
63
+ store.add_location(id: 'a')
64
+ store.add_location(id: 'b')
65
+ end
66
+
67
+ it 'creates a directed edge' do
68
+ expect(store.connect(from: 'a', to: 'b')).to be true
69
+ end
70
+
71
+ it 'creates bidirectional edges by default' do
72
+ store.connect(from: 'a', to: 'b', distance: 2.0)
73
+ a_neighbors = store.neighbors_of(id: 'a')
74
+ b_neighbors = store.neighbors_of(id: 'b')
75
+ expect(a_neighbors.map { |n| n[:id] }).to include('b')
76
+ expect(b_neighbors.map { |n| n[:id] }).to include('a')
77
+ end
78
+
79
+ it 'creates unidirectional edges when bidirectional: false' do
80
+ store.connect(from: 'a', to: 'b', bidirectional: false)
81
+ a_neighbors = store.neighbors_of(id: 'a')
82
+ b_neighbors = store.neighbors_of(id: 'b')
83
+ expect(a_neighbors.map { |n| n[:id] }).to include('b')
84
+ expect(b_neighbors.map { |n| n[:id] }).not_to include('a')
85
+ end
86
+
87
+ it 'returns false if a location is missing' do
88
+ expect(store.connect(from: 'a', to: 'missing')).to be false
89
+ end
90
+ end
91
+
92
+ describe '#disconnect' do
93
+ it 'removes an edge' do
94
+ store.add_location(id: 'a')
95
+ store.add_location(id: 'b')
96
+ store.connect(from: 'a', to: 'b')
97
+ store.disconnect(from: 'a', to: 'b')
98
+ expect(store.neighbors_of(id: 'a')).to be_empty
99
+ end
100
+
101
+ it 'returns false for unknown location' do
102
+ expect(store.disconnect(from: 'ghost', to: 'b')).to be false
103
+ end
104
+ end
105
+
106
+ describe '#visit' do
107
+ it 'marks location as visited' do
108
+ store.add_location(id: 'loc1')
109
+ result = store.visit(id: 'loc1')
110
+ expect(result[:found]).to be true
111
+ expect(result[:visit_count]).to eq(1)
112
+ end
113
+
114
+ it 'returns found: false for unknown location' do
115
+ result = store.visit(id: 'ghost')
116
+ expect(result[:found]).to be false
117
+ end
118
+
119
+ it 'boosts familiarity' do
120
+ store.add_location(id: 'loc1')
121
+ before = store.location('loc1').familiarity
122
+ store.visit(id: 'loc1')
123
+ expect(store.location('loc1').familiarity).to be > before
124
+ end
125
+ end
126
+
127
+ describe '#shortest_path' do
128
+ before { add_triangle }
129
+
130
+ it 'finds direct path between neighbors' do
131
+ result = store.shortest_path(from: 'a', to: 'b')
132
+ expect(result[:found]).to be true
133
+ expect(result[:path]).to eq(%w[a b])
134
+ expect(result[:distance]).to eq(1.0)
135
+ end
136
+
137
+ it 'finds shortest path avoiding longer direct edge' do
138
+ result = store.shortest_path(from: 'a', to: 'c')
139
+ expect(result[:found]).to be true
140
+ # a->b->c = 2.0, a->c = 3.0, so Dijkstra should pick a->b->c
141
+ expect(result[:distance]).to be <= 2.0
142
+ end
143
+
144
+ it 'returns trivial path for same start and end' do
145
+ result = store.shortest_path(from: 'a', to: 'a')
146
+ expect(result[:found]).to be true
147
+ expect(result[:path]).to eq(['a'])
148
+ expect(result[:distance]).to eq(0.0)
149
+ end
150
+
151
+ it 'returns found: false for missing start' do
152
+ result = store.shortest_path(from: 'ghost', to: 'a')
153
+ expect(result[:found]).to be false
154
+ end
155
+
156
+ it 'returns found: false for disconnected locations' do
157
+ store.add_location(id: 'island')
158
+ result = store.shortest_path(from: 'a', to: 'island')
159
+ expect(result[:found]).to be false
160
+ end
161
+
162
+ it 'caches repeated queries' do
163
+ store.shortest_path(from: 'a', to: 'c')
164
+ result2 = store.shortest_path(from: 'a', to: 'c')
165
+ expect(result2[:found]).to be true
166
+ end
167
+ end
168
+
169
+ describe '#neighbors_of' do
170
+ it 'returns direct neighbors' do
171
+ store.add_location(id: 'a')
172
+ store.add_location(id: 'b')
173
+ store.connect(from: 'a', to: 'b', distance: 1.5)
174
+ neighbors = store.neighbors_of(id: 'a')
175
+ expect(neighbors.size).to eq(1)
176
+ expect(neighbors.first[:id]).to eq('b')
177
+ expect(neighbors.first[:distance]).to eq(1.5)
178
+ end
179
+
180
+ it 'returns empty array for unknown location' do
181
+ expect(store.neighbors_of(id: 'ghost')).to be_empty
182
+ end
183
+
184
+ it 'includes distance category' do
185
+ store.add_location(id: 'a')
186
+ store.add_location(id: 'b')
187
+ store.connect(from: 'a', to: 'b', distance: 0.3)
188
+ neighbor = store.neighbors_of(id: 'a').first
189
+ expect(neighbor[:category]).to eq(:adjacent)
190
+ end
191
+ end
192
+
193
+ describe '#reachable_from' do
194
+ before { add_triangle }
195
+
196
+ it 'returns locations within max_distance' do
197
+ reachable = store.reachable_from(id: 'a', max_distance: 1.5)
198
+ ids = reachable.map { |r| r[:id] }
199
+ expect(ids).to include('b')
200
+ end
201
+
202
+ it 'excludes start location from results' do
203
+ reachable = store.reachable_from(id: 'a', max_distance: 5.0)
204
+ expect(reachable.map { |r| r[:id] }).not_to include('a')
205
+ end
206
+
207
+ it 'returns empty for isolated location' do
208
+ store.add_location(id: 'island')
209
+ expect(store.reachable_from(id: 'island', max_distance: 10.0)).to be_empty
210
+ end
211
+
212
+ it 'sorts results by distance' do
213
+ reachable = store.reachable_from(id: 'a', max_distance: 5.0)
214
+ distances = reachable.map { |r| r[:distance] }
215
+ expect(distances).to eq(distances.sort)
216
+ end
217
+ end
218
+
219
+ describe '#clusters' do
220
+ it 'returns one cluster for a connected graph' do
221
+ add_triangle
222
+ expect(store.clusters.size).to eq(1)
223
+ end
224
+
225
+ it 'returns multiple clusters for disconnected graph' do
226
+ store.add_location(id: 'a')
227
+ store.add_location(id: 'b')
228
+ store.add_location(id: 'island')
229
+ store.connect(from: 'a', to: 'b')
230
+ clusters = store.clusters
231
+ expect(clusters.size).to eq(2)
232
+ end
233
+
234
+ it 'returns empty array for empty map' do
235
+ expect(store.clusters).to be_empty
236
+ end
237
+ end
238
+
239
+ describe '#most_familiar' do
240
+ it 'returns top N locations by familiarity' do
241
+ 5.times { |i| store.add_location(id: "loc_#{i}") }
242
+ 3.times { store.visit(id: 'loc_0') }
243
+ store.visit(id: 'loc_1')
244
+ result = store.most_familiar(n: 2)
245
+ expect(result.size).to eq(2)
246
+ expect(result.first[:id]).to eq('loc_0')
247
+ end
248
+
249
+ it 'returns fewer results than n when map has fewer locations' do
250
+ store.add_location(id: 'only_one')
251
+ result = store.most_familiar(n: 10)
252
+ expect(result.size).to eq(1)
253
+ end
254
+ end
255
+
256
+ describe '#decay_all' do
257
+ it 'decays familiarity of all locations' do
258
+ store.add_location(id: 'a')
259
+ store.visit(id: 'a')
260
+ before = store.location('a').familiarity
261
+ store.decay_all
262
+ expect(store.location('a').familiarity).to be < before
263
+ end
264
+
265
+ it 'prunes faded locations' do
266
+ store.add_location(id: 'fresh')
267
+ store.add_location(id: 'faded')
268
+ # faded has floor familiarity and 0 visits — already qualifies as faded
269
+ result = store.decay_all
270
+ expect(result[:pruned]).to be >= 0
271
+ end
272
+
273
+ it 'returns decay and prune counts' do
274
+ store.add_location(id: 'a')
275
+ store.visit(id: 'a')
276
+ result = store.decay_all
277
+ expect(result).to include(:decayed, :pruned)
278
+ end
279
+ end
280
+
281
+ describe '#context_switch' do
282
+ it 'switches to a new context' do
283
+ result = store.context_switch(context_id: :work)
284
+ expect(result[:switched]).to be true
285
+ expect(result[:context]).to eq(:work)
286
+ end
287
+
288
+ it 'new context starts empty' do
289
+ store.add_location(id: 'default_loc')
290
+ store.context_switch(context_id: :new_context)
291
+ expect(store.location_count).to eq(0)
292
+ end
293
+
294
+ it 'preserves locations in original context' do
295
+ store.add_location(id: 'default_loc')
296
+ store.context_switch(context_id: :other)
297
+ store.context_switch(context_id: :default)
298
+ expect(store.location('default_loc')).not_to be_nil
299
+ end
300
+
301
+ it 'returns failure when MAX_CONTEXTS exceeded' do
302
+ max = Legion::Extensions::CognitiveMap::Helpers::Constants::MAX_CONTEXTS
303
+ max.times { |i| store.context_switch(context_id: :"ctx_#{i}") }
304
+ result = store.context_switch(context_id: :overflow)
305
+ expect(result[:switched]).to be false
306
+ end
307
+ end
308
+
309
+ describe '#to_h' do
310
+ it 'returns stats hash with expected keys' do
311
+ stats = store.to_h
312
+ expect(stats).to include(:context, :context_count, :location_count, :edge_count, :visit_history, :cached_paths)
313
+ end
314
+
315
+ it 'reflects current location count' do
316
+ store.add_location(id: 'a')
317
+ store.add_location(id: 'b')
318
+ expect(store.to_h[:location_count]).to eq(2)
319
+ end
320
+ end
321
+ end
@@ -0,0 +1,165 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::CognitiveMap::Helpers::Location do
4
+ subject(:location) { described_class.new(id: 'concept:ruby', domain: :programming) }
5
+
6
+ describe '#initialize' do
7
+ it 'sets id and domain' do
8
+ expect(location.id).to eq('concept:ruby')
9
+ expect(location.domain).to eq(:programming)
10
+ end
11
+
12
+ it 'starts with floor familiarity' do
13
+ expect(location.familiarity).to eq(Legion::Extensions::CognitiveMap::Helpers::Constants::FAMILIARITY_FLOOR)
14
+ end
15
+
16
+ it 'starts with zero visit count' do
17
+ expect(location.visit_count).to eq(0)
18
+ end
19
+
20
+ it 'starts with no neighbors' do
21
+ expect(location.neighbors).to be_empty
22
+ end
23
+
24
+ it 'starts with no last_visited' do
25
+ expect(location.last_visited).to be_nil
26
+ end
27
+ end
28
+
29
+ describe '#visit' do
30
+ it 'increments visit count' do
31
+ location.visit
32
+ expect(location.visit_count).to eq(1)
33
+ end
34
+
35
+ it 'sets last_visited' do
36
+ before = Time.now.utc
37
+ location.visit
38
+ expect(location.last_visited).to be >= before
39
+ end
40
+
41
+ it 'boosts familiarity' do
42
+ before = location.familiarity
43
+ location.visit
44
+ expect(location.familiarity).to be > before
45
+ end
46
+
47
+ it 'clamps familiarity at 1.0' do
48
+ 20.times { location.visit }
49
+ expect(location.familiarity).to be <= 1.0
50
+ end
51
+
52
+ it 'accumulates multiple visits' do
53
+ 3.times { location.visit }
54
+ expect(location.visit_count).to eq(3)
55
+ end
56
+ end
57
+
58
+ describe '#add_neighbor' do
59
+ it 'adds a neighbor with default distance' do
60
+ location.add_neighbor('concept:python')
61
+ expect(location.neighbors).to have_key('concept:python')
62
+ expect(location.neighbors['concept:python']).to eq(Legion::Extensions::CognitiveMap::Helpers::Constants::DEFAULT_DISTANCE)
63
+ end
64
+
65
+ it 'adds a neighbor with custom distance' do
66
+ location.add_neighbor('concept:java', distance: 2.5)
67
+ expect(location.neighbors['concept:java']).to eq(2.5)
68
+ end
69
+
70
+ it 'enforces distance floor' do
71
+ location.add_neighbor('concept:c', distance: 0.0)
72
+ expect(location.neighbors['concept:c']).to eq(Legion::Extensions::CognitiveMap::Helpers::Constants::DISTANCE_FLOOR)
73
+ end
74
+
75
+ it 'updates existing neighbor distance' do
76
+ location.add_neighbor('concept:python', distance: 1.0)
77
+ location.add_neighbor('concept:python', distance: 0.5)
78
+ expect(location.neighbors['concept:python']).to eq(0.5)
79
+ end
80
+
81
+ it 'respects MAX_EDGES_PER_LOCATION limit' do
82
+ max = Legion::Extensions::CognitiveMap::Helpers::Constants::MAX_EDGES_PER_LOCATION
83
+ (max + 5).times { |i| location.add_neighbor("neighbor_#{i}") }
84
+ expect(location.neighbors.size).to be <= max
85
+ end
86
+ end
87
+
88
+ describe '#remove_neighbor' do
89
+ it 'removes an existing neighbor' do
90
+ location.add_neighbor('concept:python')
91
+ location.remove_neighbor('concept:python')
92
+ expect(location.neighbors).not_to have_key('concept:python')
93
+ end
94
+
95
+ it 'is a no-op for unknown neighbors' do
96
+ expect { location.remove_neighbor('nonexistent') }.not_to raise_error
97
+ end
98
+ end
99
+
100
+ describe '#decay' do
101
+ it 'reduces familiarity' do
102
+ location.visit # raise above floor first
103
+ before = location.familiarity
104
+ location.decay
105
+ expect(location.familiarity).to be < before
106
+ end
107
+
108
+ it 'does not go below FAMILIARITY_FLOOR' do
109
+ 10.times { location.decay }
110
+ expect(location.familiarity).to be >= Legion::Extensions::CognitiveMap::Helpers::Constants::FAMILIARITY_FLOOR
111
+ end
112
+ end
113
+
114
+ describe '#faded?' do
115
+ it 'is true for a new location at floor familiarity with zero visits' do
116
+ expect(location.familiarity).to eq(Legion::Extensions::CognitiveMap::Helpers::Constants::FAMILIARITY_FLOOR)
117
+ expect(location.visit_count).to eq(0)
118
+ expect(location.faded?).to be true
119
+ end
120
+
121
+ it 'is false after visiting' do
122
+ location.visit
123
+ expect(location.faded?).to be false
124
+ end
125
+
126
+ it 'is false when familiarity is above floor even with zero visits' do
127
+ # familiarity raised manually above floor
128
+ 2.times { location.visit }
129
+ # simulate decay back but not below floor — visit_count > 0 so still false
130
+ 10.times { location.decay }
131
+ expect(location.faded?).to be false
132
+ end
133
+ end
134
+
135
+ describe '#label' do
136
+ it 'returns a familiarity level symbol' do
137
+ expect(location.label).to be_a(Symbol)
138
+ end
139
+
140
+ it 'returns :unknown for low familiarity' do
141
+ # familiarity_floor is 0.05, which is in the :unknown range (0.0...0.2)
142
+ expect(location.label).to eq(:unknown)
143
+ end
144
+
145
+ it 'returns :intimate for high familiarity' do
146
+ 10.times { location.visit }
147
+ expect(location.label).to eq(:intimate)
148
+ end
149
+ end
150
+
151
+ describe '#to_h' do
152
+ it 'returns a hash with expected keys' do
153
+ h = location.to_h
154
+ expect(h).to include(:id, :domain, :properties, :familiarity, :label, :visit_count, :neighbor_ids, :edge_count)
155
+ end
156
+
157
+ it 'reflects current state' do
158
+ location.visit
159
+ location.add_neighbor('other')
160
+ h = location.to_h
161
+ expect(h[:visit_count]).to eq(1)
162
+ expect(h[:edge_count]).to eq(1)
163
+ end
164
+ end
165
+ end