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.
- checksums.yaml +7 -0
- data/Gemfile +11 -0
- data/lex-cognitive-map.gemspec +29 -0
- data/lib/legion/extensions/cognitive_map/actors/decay.rb +41 -0
- data/lib/legion/extensions/cognitive_map/client.rb +25 -0
- data/lib/legion/extensions/cognitive_map/helpers/cognitive_map_store.rb +175 -0
- data/lib/legion/extensions/cognitive_map/helpers/constants.rb +67 -0
- data/lib/legion/extensions/cognitive_map/helpers/graph_traversal.rb +120 -0
- data/lib/legion/extensions/cognitive_map/helpers/location.rb +67 -0
- data/lib/legion/extensions/cognitive_map/runners/cognitive_map.rb +91 -0
- data/lib/legion/extensions/cognitive_map/version.rb +9 -0
- data/lib/legion/extensions/cognitive_map.rb +16 -0
- data/spec/legion/extensions/cognitive_map/client_spec.rb +89 -0
- data/spec/legion/extensions/cognitive_map/helpers/cognitive_map_store_spec.rb +321 -0
- data/spec/legion/extensions/cognitive_map/helpers/location_spec.rb +165 -0
- data/spec/legion/extensions/cognitive_map/runners/cognitive_map_spec.rb +190 -0
- data/spec/spec_helper.rb +20 -0
- metadata +77 -0
|
@@ -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
|