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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: ad240137c690c873c8fba327efe82c4a668c09702c4eec867fcb5ac55130bd47
|
|
4
|
+
data.tar.gz: 9b016dc621682fc8c6875f3dd2bb962c06da483d7397ba46ee7cebb5829940d7
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: d895cc4c83448bdbe6a2fa7fc2a3519d3678738c964b9d176af16e09247387f3378e42cb04823f65beccca8f272ece16830e30d25eceeb7319b1915789ad8ca5
|
|
7
|
+
data.tar.gz: a4e631f5fdd5c63372a2ac88b2f407d2626255faff4e57b1d18f50b97e937d21a4ee0d119e8d00620bd7624ffdbebc3e0be3a4c115c527cc5d6b7d011b7ef699
|
data/Gemfile
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'lib/legion/extensions/cognitive_map/version'
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = 'lex-cognitive-map'
|
|
7
|
+
spec.version = Legion::Extensions::CognitiveMap::VERSION
|
|
8
|
+
spec.authors = ['Esity']
|
|
9
|
+
spec.email = ['matthewdiverson@gmail.com']
|
|
10
|
+
|
|
11
|
+
spec.summary = 'LEX Cognitive Map'
|
|
12
|
+
spec.description = "Tolman's cognitive map + O'Keefe/Moser place and grid cell theory for brain-modeled agentic AI"
|
|
13
|
+
spec.homepage = 'https://github.com/LegionIO/lex-cognitive-map'
|
|
14
|
+
spec.license = 'MIT'
|
|
15
|
+
spec.required_ruby_version = '>= 3.4'
|
|
16
|
+
|
|
17
|
+
spec.metadata['homepage_uri'] = spec.homepage
|
|
18
|
+
spec.metadata['source_code_uri'] = 'https://github.com/LegionIO/lex-cognitive-map'
|
|
19
|
+
spec.metadata['documentation_uri'] = 'https://github.com/LegionIO/lex-cognitive-map'
|
|
20
|
+
spec.metadata['changelog_uri'] = 'https://github.com/LegionIO/lex-cognitive-map'
|
|
21
|
+
spec.metadata['bug_tracker_uri'] = 'https://github.com/LegionIO/lex-cognitive-map/issues'
|
|
22
|
+
spec.metadata['rubygems_mfa_required'] = 'true'
|
|
23
|
+
|
|
24
|
+
spec.files = Dir.chdir(File.expand_path(__dir__)) do
|
|
25
|
+
Dir.glob('{lib,spec}/**/*') + %w[lex-cognitive-map.gemspec Gemfile]
|
|
26
|
+
end
|
|
27
|
+
spec.require_paths = ['lib']
|
|
28
|
+
spec.add_development_dependency 'legion-gaia'
|
|
29
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/actors/every'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module Extensions
|
|
7
|
+
module CognitiveMap
|
|
8
|
+
module Actor
|
|
9
|
+
class Decay < Legion::Extensions::Actors::Every
|
|
10
|
+
def runner_class
|
|
11
|
+
Legion::Extensions::CognitiveMap::Runners::CognitiveMap
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def runner_function
|
|
15
|
+
'update_cognitive_map'
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def time
|
|
19
|
+
60
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def run_now?
|
|
23
|
+
false
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def use_runner?
|
|
27
|
+
false
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def check_subtask?
|
|
31
|
+
false
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def generate_task?
|
|
35
|
+
false
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/cognitive_map/helpers/constants'
|
|
4
|
+
require 'legion/extensions/cognitive_map/helpers/location'
|
|
5
|
+
require 'legion/extensions/cognitive_map/helpers/graph_traversal'
|
|
6
|
+
require 'legion/extensions/cognitive_map/helpers/cognitive_map_store'
|
|
7
|
+
require 'legion/extensions/cognitive_map/runners/cognitive_map'
|
|
8
|
+
|
|
9
|
+
module Legion
|
|
10
|
+
module Extensions
|
|
11
|
+
module CognitiveMap
|
|
12
|
+
class Client
|
|
13
|
+
include Runners::CognitiveMap
|
|
14
|
+
|
|
15
|
+
def initialize(map_store: nil, **)
|
|
16
|
+
@map_store = map_store || Helpers::CognitiveMapStore.new
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
attr_reader :map_store
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module CognitiveMap
|
|
6
|
+
module Helpers
|
|
7
|
+
class CognitiveMapStore
|
|
8
|
+
attr_reader :current_context
|
|
9
|
+
|
|
10
|
+
def initialize
|
|
11
|
+
@contexts = {}
|
|
12
|
+
@current_context = :default
|
|
13
|
+
@path_cache = {}
|
|
14
|
+
@visit_history = []
|
|
15
|
+
ensure_context(@current_context)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def add_location(id:, domain: :general, properties: {})
|
|
19
|
+
return false if locations.size >= Constants::MAX_LOCATIONS && !locations.key?(id)
|
|
20
|
+
|
|
21
|
+
locations[id] ||= Location.new(id: id, domain: domain, properties: properties)
|
|
22
|
+
true
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def location(id)
|
|
26
|
+
locations[id]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def remove_location(id)
|
|
30
|
+
loc = locations.delete(id)
|
|
31
|
+
return false unless loc
|
|
32
|
+
|
|
33
|
+
locations.each_value { |l| l.remove_neighbor(id) }
|
|
34
|
+
invalidate_path_cache
|
|
35
|
+
true
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def connect(from:, to:, distance: Constants::DEFAULT_DISTANCE, bidirectional: true)
|
|
39
|
+
return false unless locations.key?(from) && locations.key?(to)
|
|
40
|
+
|
|
41
|
+
locations[from].add_neighbor(to, distance: distance)
|
|
42
|
+
locations[to].add_neighbor(from, distance: distance) if bidirectional
|
|
43
|
+
invalidate_path_cache
|
|
44
|
+
true
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def disconnect(from:, to:)
|
|
48
|
+
return false unless locations.key?(from)
|
|
49
|
+
|
|
50
|
+
locations[from].remove_neighbor(to)
|
|
51
|
+
invalidate_path_cache
|
|
52
|
+
true
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def visit(id:)
|
|
56
|
+
loc = locations[id]
|
|
57
|
+
return { found: false } unless loc
|
|
58
|
+
|
|
59
|
+
loc.visit
|
|
60
|
+
record_visit(id)
|
|
61
|
+
{ found: true, id: id, visit_count: loc.visit_count, familiarity: loc.familiarity.round(4) }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def shortest_path(from:, to:)
|
|
65
|
+
return { found: false, reason: :missing_start } unless locations.key?(from)
|
|
66
|
+
return { found: false, reason: :missing_end } unless locations.key?(to)
|
|
67
|
+
return { found: true, path: [from], distance: 0.0 } if from == to
|
|
68
|
+
|
|
69
|
+
fetch_or_compute_path(from, to)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def neighbors_of(id:)
|
|
73
|
+
loc = locations[id]
|
|
74
|
+
return [] unless loc
|
|
75
|
+
|
|
76
|
+
loc.neighbors.map { |nid, dist| { id: nid, distance: dist, category: Constants.distance_category(dist) } }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def reachable_from(id:, max_distance: 3.0)
|
|
80
|
+
return [] unless locations.key?(id)
|
|
81
|
+
|
|
82
|
+
GraphTraversal.bfs_reachable(locations, id, max_distance)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def clusters
|
|
86
|
+
GraphTraversal.connected_components(locations)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def most_familiar(n: 10)
|
|
90
|
+
locations.values.sort_by { |l| -l.familiarity }.first(n).map(&:to_h)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def decay_all
|
|
94
|
+
faded_ids = collect_faded
|
|
95
|
+
faded_ids.each { |id| remove_location(id) }
|
|
96
|
+
invalidate_path_cache if faded_ids.any?
|
|
97
|
+
{ decayed: locations.size, pruned: faded_ids.size }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def context_switch(context_id:)
|
|
101
|
+
return { switched: false, reason: :max_contexts } if over_context_limit?(context_id)
|
|
102
|
+
|
|
103
|
+
ensure_context(context_id)
|
|
104
|
+
@current_context = context_id
|
|
105
|
+
invalidate_path_cache
|
|
106
|
+
{ switched: true, context: context_id, location_count: locations.size }
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def to_h
|
|
110
|
+
{
|
|
111
|
+
context: @current_context,
|
|
112
|
+
context_count: @contexts.size,
|
|
113
|
+
location_count: locations.size,
|
|
114
|
+
edge_count: total_edges,
|
|
115
|
+
visit_history: @visit_history.size,
|
|
116
|
+
cached_paths: @path_cache.size
|
|
117
|
+
}
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def location_count
|
|
121
|
+
locations.size
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
private
|
|
125
|
+
|
|
126
|
+
def locations
|
|
127
|
+
@contexts[@current_context]
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def ensure_context(context_id)
|
|
131
|
+
@contexts[context_id] ||= {}
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def over_context_limit?(context_id)
|
|
135
|
+
@contexts.size >= Constants::MAX_CONTEXTS && !@contexts.key?(context_id)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def record_visit(id)
|
|
139
|
+
@visit_history << { id: id, at: Time.now.utc }
|
|
140
|
+
@visit_history.shift if @visit_history.size > Constants::MAX_VISIT_HISTORY
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def invalidate_path_cache
|
|
144
|
+
@path_cache.clear
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def total_edges
|
|
148
|
+
locations.values.sum { |l| l.neighbors.size }
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def collect_faded
|
|
152
|
+
faded = []
|
|
153
|
+
locations.each_value do |loc|
|
|
154
|
+
loc.decay
|
|
155
|
+
faded << loc.id if loc.faded?
|
|
156
|
+
end
|
|
157
|
+
faded
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def fetch_or_compute_path(from, to)
|
|
161
|
+
cache_key = "#{@current_context}:#{from}->#{to}"
|
|
162
|
+
return @path_cache[cache_key] if @path_cache[cache_key]
|
|
163
|
+
|
|
164
|
+
result = GraphTraversal.dijkstra(locations, from, to)
|
|
165
|
+
if result[:found]
|
|
166
|
+
@path_cache[cache_key] = result
|
|
167
|
+
@path_cache.shift if @path_cache.size > Constants::MAX_PATHS_CACHED
|
|
168
|
+
end
|
|
169
|
+
result
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module CognitiveMap
|
|
6
|
+
module Helpers
|
|
7
|
+
module Constants
|
|
8
|
+
# Graph size limits
|
|
9
|
+
MAX_LOCATIONS = 500
|
|
10
|
+
MAX_EDGES_PER_LOCATION = 20
|
|
11
|
+
MAX_PATHS_CACHED = 100
|
|
12
|
+
|
|
13
|
+
# Edge weights
|
|
14
|
+
DEFAULT_DISTANCE = 1.0
|
|
15
|
+
DISTANCE_FLOOR = 0.01
|
|
16
|
+
|
|
17
|
+
# Familiarity EMA
|
|
18
|
+
FAMILIARITY_ALPHA = 0.12
|
|
19
|
+
FAMILIARITY_DECAY = 0.005
|
|
20
|
+
FAMILIARITY_FLOOR = 0.05
|
|
21
|
+
VISIT_BOOST = 0.1
|
|
22
|
+
|
|
23
|
+
# Visit history ring buffer
|
|
24
|
+
MAX_VISIT_HISTORY = 300
|
|
25
|
+
|
|
26
|
+
# Context remapping
|
|
27
|
+
REMAP_THRESHOLD = 0.5
|
|
28
|
+
MAX_CONTEXTS = 10
|
|
29
|
+
|
|
30
|
+
# Familiarity level labels (ascending thresholds)
|
|
31
|
+
FAMILIARITY_LEVELS = {
|
|
32
|
+
(0.0...0.2) => :unknown,
|
|
33
|
+
(0.2...0.4) => :sparse,
|
|
34
|
+
(0.4...0.6) => :moderate,
|
|
35
|
+
(0.6...0.8) => :familiar,
|
|
36
|
+
(0.8..1.0) => :intimate
|
|
37
|
+
}.freeze
|
|
38
|
+
|
|
39
|
+
# Distance category labels (ascending thresholds)
|
|
40
|
+
DISTANCE_CATEGORIES = {
|
|
41
|
+
(0.0...0.5) => :adjacent,
|
|
42
|
+
(0.5...1.5) => :near,
|
|
43
|
+
(1.5...3.0) => :moderate,
|
|
44
|
+
(3.0...6.0) => :distant,
|
|
45
|
+
(6.0..) => :remote
|
|
46
|
+
}.freeze
|
|
47
|
+
|
|
48
|
+
module_function
|
|
49
|
+
|
|
50
|
+
def familiarity_level(value)
|
|
51
|
+
FAMILIARITY_LEVELS.each do |range, label|
|
|
52
|
+
return label if range.cover?(value)
|
|
53
|
+
end
|
|
54
|
+
:intimate
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def distance_category(value)
|
|
58
|
+
DISTANCE_CATEGORIES.each do |range, label|
|
|
59
|
+
return label if range.cover?(value)
|
|
60
|
+
end
|
|
61
|
+
:remote
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module CognitiveMap
|
|
6
|
+
module Helpers
|
|
7
|
+
module GraphTraversal
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def dijkstra(locations, from, to)
|
|
11
|
+
dist = Hash.new(Float::INFINITY)
|
|
12
|
+
prev = {}
|
|
13
|
+
dist[from] = 0.0
|
|
14
|
+
queue = [[0.0, from]]
|
|
15
|
+
|
|
16
|
+
until queue.empty?
|
|
17
|
+
d, u = queue.min_by { |x, _| x }
|
|
18
|
+
queue.delete([d, u])
|
|
19
|
+
break if u == to
|
|
20
|
+
next if d > dist[u]
|
|
21
|
+
|
|
22
|
+
relax_edges(locations, u, dist, prev, queue)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
build_path_result(locations, prev, from, to, dist[to])
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def bfs_reachable(locations, start, max_distance)
|
|
29
|
+
visited = { start => 0.0 }
|
|
30
|
+
queue = [[0.0, start]]
|
|
31
|
+
reachable = []
|
|
32
|
+
|
|
33
|
+
until queue.empty?
|
|
34
|
+
dist, current = queue.min_by { |d, _| d }
|
|
35
|
+
queue.delete([dist, current])
|
|
36
|
+
next if dist > max_distance
|
|
37
|
+
|
|
38
|
+
reachable << { id: current, distance: dist.round(4) } unless current == start
|
|
39
|
+
expand_neighbors(locations, current, dist, max_distance, visited, queue)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
reachable.sort_by { |r| r[:distance] }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def connected_components(locations)
|
|
46
|
+
visited = {}
|
|
47
|
+
components = []
|
|
48
|
+
locations.each_key do |id|
|
|
49
|
+
next if visited[id]
|
|
50
|
+
|
|
51
|
+
component = []
|
|
52
|
+
bfs_component(locations, id, visited, component)
|
|
53
|
+
components << component
|
|
54
|
+
end
|
|
55
|
+
components
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def relax_edges(locations, u, dist, prev, queue)
|
|
59
|
+
loc = locations[u]
|
|
60
|
+
return unless loc
|
|
61
|
+
|
|
62
|
+
loc.neighbors.each do |v, weight|
|
|
63
|
+
alt = dist[u] + weight
|
|
64
|
+
next unless alt < dist[v]
|
|
65
|
+
|
|
66
|
+
dist[v] = alt
|
|
67
|
+
prev[v] = u
|
|
68
|
+
queue << [alt, v]
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def build_path_result(_locations, prev, from, to, cost)
|
|
73
|
+
return { found: false, reason: :no_path } if cost == Float::INFINITY
|
|
74
|
+
|
|
75
|
+
path = reconstruct_path(prev, from, to)
|
|
76
|
+
path.empty? ? { found: false, reason: :no_path } : { found: true, path: path, distance: cost.round(4) }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def reconstruct_path(prev, from, to)
|
|
80
|
+
path = []
|
|
81
|
+
current = to
|
|
82
|
+
while current
|
|
83
|
+
path.unshift(current)
|
|
84
|
+
current = prev[current]
|
|
85
|
+
end
|
|
86
|
+
path.first == from ? path : []
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def expand_neighbors(locations, current, dist, max_distance, visited, queue)
|
|
90
|
+
loc = locations[current]
|
|
91
|
+
return unless loc
|
|
92
|
+
|
|
93
|
+
loc.neighbors.each do |nid, edge_dist|
|
|
94
|
+
new_dist = dist + edge_dist
|
|
95
|
+
next if new_dist > max_distance
|
|
96
|
+
next if visited.key?(nid) && visited[nid] <= new_dist
|
|
97
|
+
|
|
98
|
+
visited[nid] = new_dist
|
|
99
|
+
queue << [new_dist, nid]
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def bfs_component(locations, start, visited, component)
|
|
104
|
+
queue = [start]
|
|
105
|
+
while (current = queue.shift)
|
|
106
|
+
next if visited[current]
|
|
107
|
+
|
|
108
|
+
visited[current] = true
|
|
109
|
+
component << current
|
|
110
|
+
loc = locations[current]
|
|
111
|
+
next unless loc
|
|
112
|
+
|
|
113
|
+
loc.neighbors.each_key { |nid| queue << nid unless visited[nid] }
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module CognitiveMap
|
|
6
|
+
module Helpers
|
|
7
|
+
class Location
|
|
8
|
+
attr_reader :id, :domain, :properties, :visit_count, :last_visited, :neighbors, :familiarity
|
|
9
|
+
|
|
10
|
+
def initialize(id:, domain: :general, properties: {})
|
|
11
|
+
@id = id
|
|
12
|
+
@domain = domain
|
|
13
|
+
@properties = properties
|
|
14
|
+
@familiarity = Constants::FAMILIARITY_FLOOR
|
|
15
|
+
@visit_count = 0
|
|
16
|
+
@last_visited = nil
|
|
17
|
+
@neighbors = {} # neighbor_id => distance
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def visit
|
|
21
|
+
@visit_count += 1
|
|
22
|
+
@last_visited = Time.now.utc
|
|
23
|
+
@familiarity = [@familiarity + Constants::VISIT_BOOST, 1.0].min
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def add_neighbor(neighbor_id, distance: Constants::DEFAULT_DISTANCE)
|
|
27
|
+
distance = [distance, Constants::DISTANCE_FLOOR].max
|
|
28
|
+
return if @neighbors.size >= Constants::MAX_EDGES_PER_LOCATION && !@neighbors.key?(neighbor_id)
|
|
29
|
+
|
|
30
|
+
@neighbors[neighbor_id] = distance
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def remove_neighbor(neighbor_id)
|
|
34
|
+
@neighbors.delete(neighbor_id)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def decay
|
|
38
|
+
new_val = @familiarity - Constants::FAMILIARITY_DECAY
|
|
39
|
+
@familiarity = [new_val, Constants::FAMILIARITY_FLOOR].max
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def faded?
|
|
43
|
+
@familiarity <= Constants::FAMILIARITY_FLOOR && @visit_count.zero?
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def label
|
|
47
|
+
Constants.familiarity_level(@familiarity)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def to_h
|
|
51
|
+
{
|
|
52
|
+
id: @id,
|
|
53
|
+
domain: @domain,
|
|
54
|
+
properties: @properties,
|
|
55
|
+
familiarity: @familiarity.round(4),
|
|
56
|
+
label: label,
|
|
57
|
+
visit_count: @visit_count,
|
|
58
|
+
last_visited: @last_visited,
|
|
59
|
+
neighbor_ids: @neighbors.keys,
|
|
60
|
+
edge_count: @neighbors.size
|
|
61
|
+
}
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module CognitiveMap
|
|
6
|
+
module Runners
|
|
7
|
+
module CognitiveMap
|
|
8
|
+
include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers) &&
|
|
9
|
+
Legion::Extensions::Helpers.const_defined?(:Lex)
|
|
10
|
+
|
|
11
|
+
def add_location(id:, domain: :general, properties: {}, **)
|
|
12
|
+
result = map_store.add_location(id: id, domain: domain, properties: properties)
|
|
13
|
+
if result
|
|
14
|
+
Legion::Logging.debug "[cognitive_map] add_location id=#{id} domain=#{domain}"
|
|
15
|
+
{ success: true, id: id, domain: domain }
|
|
16
|
+
else
|
|
17
|
+
Legion::Logging.warn "[cognitive_map] add_location failed: capacity or duplicate id=#{id}"
|
|
18
|
+
{ success: false, id: id, reason: :capacity_or_duplicate }
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def connect_locations(from:, to:, distance: Helpers::Constants::DEFAULT_DISTANCE,
|
|
23
|
+
bidirectional: true, **)
|
|
24
|
+
result = map_store.connect(from: from, to: to, distance: distance, bidirectional: bidirectional)
|
|
25
|
+
if result
|
|
26
|
+
Legion::Logging.debug "[cognitive_map] connect from=#{from} to=#{to} distance=#{distance}"
|
|
27
|
+
{ success: true, from: from, to: to, distance: distance, bidirectional: bidirectional }
|
|
28
|
+
else
|
|
29
|
+
Legion::Logging.warn "[cognitive_map] connect failed: missing location from=#{from} to=#{to}"
|
|
30
|
+
{ success: false, from: from, to: to, reason: :missing_location }
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def visit_location(id:, **)
|
|
35
|
+
result = map_store.visit(id: id)
|
|
36
|
+
Legion::Logging.debug "[cognitive_map] visit id=#{id} found=#{result[:found]}"
|
|
37
|
+
result.merge(success: result[:found])
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def find_path(from:, to:, **)
|
|
41
|
+
result = map_store.shortest_path(from: from, to: to)
|
|
42
|
+
Legion::Logging.debug "[cognitive_map] find_path from=#{from} to=#{to} found=#{result[:found]}"
|
|
43
|
+
result.merge(success: result[:found])
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def explore_neighborhood(id:, max_distance: 3.0, **)
|
|
47
|
+
reachable = map_store.reachable_from(id: id, max_distance: max_distance)
|
|
48
|
+
Legion::Logging.debug "[cognitive_map] explore id=#{id} max_distance=#{max_distance} found=#{reachable.size}"
|
|
49
|
+
{ success: true, id: id, reachable: reachable, count: reachable.size }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def map_clusters(**)
|
|
53
|
+
components = map_store.clusters
|
|
54
|
+
Legion::Logging.debug "[cognitive_map] clusters count=#{components.size}"
|
|
55
|
+
{ success: true, clusters: components, count: components.size }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def familiar_locations(limit: 10, **)
|
|
59
|
+
locations = map_store.most_familiar(n: limit)
|
|
60
|
+
Legion::Logging.debug "[cognitive_map] familiar_locations count=#{locations.size}"
|
|
61
|
+
{ success: true, locations: locations, count: locations.size }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def switch_context(context_id:, **)
|
|
65
|
+
result = map_store.context_switch(context_id: context_id)
|
|
66
|
+
Legion::Logging.info "[cognitive_map] context_switch context_id=#{context_id} switched=#{result[:switched]}"
|
|
67
|
+
result.merge(success: result[:switched])
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def update_cognitive_map(**)
|
|
71
|
+
result = map_store.decay_all
|
|
72
|
+
Legion::Logging.debug "[cognitive_map] decay_cycle decayed=#{result[:decayed]} pruned=#{result[:pruned]}"
|
|
73
|
+
{ success: true, decayed: result[:decayed], pruned: result[:pruned] }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def cognitive_map_stats(**)
|
|
77
|
+
stats = map_store.to_h
|
|
78
|
+
Legion::Logging.debug "[cognitive_map] stats context=#{stats[:context]} locations=#{stats[:location_count]}"
|
|
79
|
+
{ success: true }.merge(stats)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def map_store
|
|
85
|
+
@map_store ||= Helpers::CognitiveMapStore.new
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/cognitive_map/version'
|
|
4
|
+
require 'legion/extensions/cognitive_map/helpers/constants'
|
|
5
|
+
require 'legion/extensions/cognitive_map/helpers/location'
|
|
6
|
+
require 'legion/extensions/cognitive_map/helpers/graph_traversal'
|
|
7
|
+
require 'legion/extensions/cognitive_map/helpers/cognitive_map_store'
|
|
8
|
+
require 'legion/extensions/cognitive_map/runners/cognitive_map'
|
|
9
|
+
|
|
10
|
+
module Legion
|
|
11
|
+
module Extensions
|
|
12
|
+
module CognitiveMap
|
|
13
|
+
extend Legion::Extensions::Core if Legion::Extensions.const_defined? :Core
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|