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 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,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ gemspec
6
+
7
+ gem 'rspec', '~> 3.13'
8
+ gem 'rubocop', '~> 1.75', require: false
9
+ gem 'rubocop-rspec', require: false
10
+
11
+ gem 'legion-gaia', path: '../../legion-gaia'
@@ -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,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module CognitiveMap
6
+ VERSION = '0.1.0'
7
+ end
8
+ end
9
+ 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