lex-analogical-reasoning 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: 07b0687586726bfa7bfdc4a13bda4aa3d0baef51df046af756878f691fb307fa
4
+ data.tar.gz: ff5d3787c7f9e431cc871166ed6b5cbf259070c1bb66804e2e4436667e0a0ba0
5
+ SHA512:
6
+ metadata.gz: 82fe91c2dd597efa14a8e442edfd4ed236732389434ff4027dbcd1bfcc2a295fda404afdb973ed6ecd42243be9f513186fc5f18a9bb6d904f6026aa9ad3ca387
7
+ data.tar.gz: b475a0e147e1f2b18d29a5a4e56afac8ea22f1d33f370240f5466f5e1200b941450b8048b345e120c918e224f97ac6d6f51271c8added6a3cd641d70c841e3f2
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,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'lib/legion/extensions/analogical_reasoning/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'lex-analogical-reasoning'
7
+ spec.version = Legion::Extensions::AnalogicalReasoning::VERSION
8
+ spec.authors = ['Esity']
9
+ spec.email = ['matthewdiverson@gmail.com']
10
+
11
+ spec.summary = 'LEX Analogical Reasoning'
12
+ spec.description = 'Analogical reasoning engine (Gentner structure mapping, Hofstadter, ' \
13
+ 'Holyoak multi-constraint) for brain-modeled agentic AI'
14
+ spec.homepage = 'https://github.com/LegionIO/lex-analogical-reasoning'
15
+ spec.license = 'MIT'
16
+ spec.required_ruby_version = '>= 3.4'
17
+
18
+ spec.metadata['homepage_uri'] = spec.homepage
19
+ spec.metadata['source_code_uri'] = 'https://github.com/LegionIO/lex-analogical-reasoning'
20
+ spec.metadata['documentation_uri'] = 'https://github.com/LegionIO/lex-analogical-reasoning'
21
+ spec.metadata['changelog_uri'] = 'https://github.com/LegionIO/lex-analogical-reasoning'
22
+ spec.metadata['bug_tracker_uri'] = 'https://github.com/LegionIO/lex-analogical-reasoning/issues'
23
+ spec.metadata['rubygems_mfa_required'] = 'true'
24
+
25
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
26
+ Dir.glob('{lib,spec}/**/*') + %w[lex-analogical-reasoning.gemspec Gemfile]
27
+ end
28
+ spec.require_paths = ['lib']
29
+ spec.add_development_dependency 'legion-gaia'
30
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/analogical_reasoning/helpers/constants'
4
+ require 'legion/extensions/analogical_reasoning/helpers/structure_map'
5
+ require 'legion/extensions/analogical_reasoning/helpers/analogy_engine'
6
+ require 'legion/extensions/analogical_reasoning/runners/analogical_reasoning'
7
+
8
+ module Legion
9
+ module Extensions
10
+ module AnalogicalReasoning
11
+ class Client
12
+ include Runners::AnalogicalReasoning
13
+
14
+ def initialize(engine: nil, **)
15
+ @engine = engine || Helpers::AnalogyEngine.new
16
+ end
17
+
18
+ private
19
+
20
+ attr_reader :engine
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,205 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module AnalogicalReasoning
6
+ module Helpers
7
+ class AnalogyEngine
8
+ include Constants
9
+
10
+ attr_reader :history
11
+
12
+ def initialize
13
+ @analogies = {}
14
+ @domains = Set.new
15
+ @history = []
16
+ end
17
+
18
+ def create_analogy(source_domain:, target_domain:, mappings:, mapping_type:, strength: nil)
19
+ evict_oldest_analogy if @analogies.size >= Constants::MAX_ANALOGIES
20
+
21
+ analogy = StructureMap.new(
22
+ source_domain: source_domain,
23
+ target_domain: target_domain,
24
+ mappings: mappings,
25
+ mapping_type: mapping_type,
26
+ strength: strength
27
+ )
28
+
29
+ @analogies[analogy.id] = analogy
30
+ register_domain(source_domain)
31
+ register_domain(target_domain)
32
+ record_history(:created, analogy.id)
33
+
34
+ analogy
35
+ end
36
+
37
+ def find_analogies(domain:)
38
+ @analogies.values.select do |analogy|
39
+ analogy.source_domain == domain || analogy.target_domain == domain
40
+ end
41
+ end
42
+
43
+ def apply_analogy(analogy_id:, source_element:)
44
+ analogy = @analogies[analogy_id]
45
+ return { found: false } unless analogy
46
+
47
+ target_element = analogy.mappings[source_element]
48
+ return { found: true, mapped: false } unless target_element
49
+
50
+ analogy.use!
51
+ record_history(:applied, analogy_id)
52
+
53
+ {
54
+ found: true,
55
+ mapped: true,
56
+ source_element: source_element,
57
+ target_element: target_element,
58
+ confidence: analogy.strength,
59
+ analogy_id: analogy_id
60
+ }
61
+ end
62
+
63
+ def evaluate_similarity(source:, target:)
64
+ return 0.0 if source.empty? || target.empty?
65
+
66
+ structural = structural_overlap(source, target)
67
+ surface = surface_overlap(source, target)
68
+ (Constants::STRUCTURAL_WEIGHT * structural) + (Constants::SURFACE_WEIGHT * surface)
69
+ end
70
+
71
+ def cross_domain_transfer(analogy_id:, source_knowledge:)
72
+ analogy = @analogies[analogy_id]
73
+ return { transferred: false, reason: :not_found } unless analogy
74
+
75
+ transferred = {}
76
+ source_knowledge.each do |key, value|
77
+ mapped_key = analogy.mappings.fetch(key, nil)
78
+ transferred[mapped_key] = value if mapped_key
79
+ end
80
+
81
+ analogy.use!
82
+ record_history(:transferred, analogy_id)
83
+
84
+ {
85
+ transferred: true,
86
+ analogy_id: analogy_id,
87
+ source_domain: analogy.source_domain,
88
+ target_domain: analogy.target_domain,
89
+ result: transferred,
90
+ coverage: coverage_ratio(source_knowledge, transferred)
91
+ }
92
+ end
93
+
94
+ def reinforce_analogy(analogy_id:, success:)
95
+ analogy = @analogies[analogy_id]
96
+ return { reinforced: false, reason: :not_found } unless analogy
97
+
98
+ if success
99
+ analogy.reinforce
100
+ record_history(:reinforced, analogy_id)
101
+ else
102
+ analogy.weaken
103
+ record_history(:weakened, analogy_id)
104
+ end
105
+
106
+ { reinforced: true, analogy_id: analogy_id, strength: analogy.strength, state: analogy.state }
107
+ end
108
+
109
+ def productive_analogies
110
+ @analogies.values.select(&:productive?)
111
+ end
112
+
113
+ def by_domain(domain:)
114
+ find_analogies(domain: domain)
115
+ end
116
+
117
+ def by_type(type:)
118
+ @analogies.values.select { |analogy| analogy.mapping_type == type }
119
+ end
120
+
121
+ def decay_all
122
+ @analogies.each_value(&:decay)
123
+ end
124
+
125
+ def prune_stale
126
+ stale_ids = @analogies.select { |_id, analogy| analogy.state == :stale }.keys
127
+ stale_ids.each { |id| @analogies.delete(id) }
128
+ stale_ids.size
129
+ end
130
+
131
+ def to_h
132
+ {
133
+ total_analogies: @analogies.size,
134
+ total_domains: @domains.size,
135
+ productive_count: productive_analogies.size,
136
+ history_count: @history.size,
137
+ domains: @domains.to_a,
138
+ analogy_states: state_counts
139
+ }
140
+ end
141
+
142
+ private
143
+
144
+ def register_domain(domain)
145
+ @domains.add(domain)
146
+ evict_oldest_domain if @domains.size > Constants::MAX_DOMAINS
147
+ end
148
+
149
+ def evict_oldest_analogy
150
+ oldest_id = @analogies.min_by { |_id, analogy| analogy.last_used_at }&.first
151
+ @analogies.delete(oldest_id) if oldest_id
152
+ end
153
+
154
+ def evict_oldest_domain
155
+ @domains.delete(@domains.first)
156
+ end
157
+
158
+ def record_history(event, analogy_id)
159
+ entry = { event: event, analogy_id: analogy_id, at: Time.now.utc }
160
+ @history << entry
161
+ @history.shift while @history.size > Constants::MAX_HISTORY
162
+ end
163
+
164
+ def structural_overlap(source, target)
165
+ source_keys = source.keys.to_set(&:to_s)
166
+ target_keys = target.keys.to_set(&:to_s)
167
+ union = source_keys | target_keys
168
+ return 0.0 if union.empty?
169
+
170
+ (source_keys & target_keys).size.to_f / union.size
171
+ end
172
+
173
+ def surface_overlap(source, target)
174
+ common = source.keys.to_set(&:to_s) & target.keys.to_set(&:to_s)
175
+ surface_match_ratio(common, source, target)
176
+ end
177
+
178
+ def surface_match_ratio(common_keys, source, target)
179
+ return 0.0 if common_keys.empty?
180
+
181
+ matches = common_keys.count do |key|
182
+ sym = key.to_sym
183
+ src_val = source.fetch(sym) { source[key] }
184
+ tgt_val = target.fetch(sym) { target[key] }
185
+ src_val == tgt_val && !src_val.nil?
186
+ end
187
+ matches.to_f / common_keys.size
188
+ end
189
+
190
+ def coverage_ratio(source_knowledge, transferred)
191
+ return 0.0 if source_knowledge.empty?
192
+
193
+ transferred.size.to_f / source_knowledge.size
194
+ end
195
+
196
+ def state_counts
197
+ @analogies.values.each_with_object(Hash.new(0)) do |analogy, counts|
198
+ counts[analogy.state] += 1
199
+ end
200
+ end
201
+ end
202
+ end
203
+ end
204
+ end
205
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module AnalogicalReasoning
6
+ module Helpers
7
+ module Constants
8
+ MAX_ANALOGIES = 200
9
+ MAX_DOMAINS = 50
10
+ MAX_HISTORY = 300
11
+
12
+ SIMILARITY_THRESHOLD = 0.4
13
+ STRUCTURAL_WEIGHT = 0.7
14
+ SURFACE_WEIGHT = 0.3
15
+
16
+ DEFAULT_STRENGTH = 0.5
17
+ STRENGTH_FLOOR = 0.05
18
+ STRENGTH_CEILING = 0.95
19
+
20
+ REINFORCEMENT_RATE = 0.1
21
+ DECAY_RATE = 0.01
22
+
23
+ MAPPING_TYPES = %i[attribute relational system].freeze
24
+ ANALOGY_STATES = %i[candidate validated productive stale].freeze
25
+ STATE_THRESHOLDS = {
26
+ productive: 0.75,
27
+ validated: 0.5,
28
+ candidate: 0.25
29
+ }.freeze
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module Legion
6
+ module Extensions
7
+ module AnalogicalReasoning
8
+ module Helpers
9
+ class StructureMap
10
+ include Constants
11
+
12
+ attr_reader :id, :source_domain, :target_domain, :mappings, :mapping_type, :times_used, :created_at,
13
+ :last_used_at, :strength
14
+
15
+ def initialize(source_domain:, target_domain:, mappings:, mapping_type:, strength: nil)
16
+ @id = SecureRandom.uuid
17
+ @source_domain = source_domain
18
+ @target_domain = target_domain
19
+ @mappings = mappings
20
+ @mapping_type = mapping_type
21
+ @strength = (strength || Constants::DEFAULT_STRENGTH).clamp(
22
+ Constants::STRENGTH_FLOOR,
23
+ Constants::STRENGTH_CEILING
24
+ )
25
+ @times_used = 0
26
+ @created_at = Time.now.utc
27
+ @last_used_at = Time.now.utc
28
+ end
29
+
30
+ def structural_score
31
+ return 0.0 if mappings.empty?
32
+
33
+ relational_count = mappings.count { |_src, tgt| tgt.is_a?(Hash) && tgt[:type] == :relational }
34
+ relational_count.to_f / mappings.size
35
+ end
36
+
37
+ def surface_score
38
+ return 0.0 if mappings.empty?
39
+
40
+ attribute_count = mappings.count { |_src, tgt| !(tgt.is_a?(Hash) && tgt[:type] == :relational) }
41
+ attribute_count.to_f / mappings.size
42
+ end
43
+
44
+ def similarity_score
45
+ (Constants::STRUCTURAL_WEIGHT * structural_score) +
46
+ (Constants::SURFACE_WEIGHT * surface_score)
47
+ end
48
+
49
+ def use!
50
+ @times_used += 1
51
+ @last_used_at = Time.now.utc
52
+ reinforce(amount: Constants::REINFORCEMENT_RATE * 0.5)
53
+ self
54
+ end
55
+
56
+ def reinforce(amount: Constants::REINFORCEMENT_RATE)
57
+ @strength = (@strength + amount).clamp(Constants::STRENGTH_FLOOR, Constants::STRENGTH_CEILING)
58
+ self
59
+ end
60
+
61
+ def weaken(amount: Constants::REINFORCEMENT_RATE)
62
+ @strength = (@strength - amount).clamp(Constants::STRENGTH_FLOOR, Constants::STRENGTH_CEILING)
63
+ self
64
+ end
65
+
66
+ def decay
67
+ @strength = (@strength - Constants::DECAY_RATE).clamp(Constants::STRENGTH_FLOOR, Constants::STRENGTH_CEILING)
68
+ self
69
+ end
70
+
71
+ def state
72
+ thresholds = Constants::STATE_THRESHOLDS
73
+ if @strength >= thresholds[:productive]
74
+ :productive
75
+ elsif @strength >= thresholds[:validated]
76
+ :validated
77
+ elsif @strength >= thresholds[:candidate]
78
+ :candidate
79
+ else
80
+ :stale
81
+ end
82
+ end
83
+
84
+ def productive?
85
+ state == :productive
86
+ end
87
+
88
+ def to_h
89
+ {
90
+ id: @id,
91
+ source_domain: @source_domain,
92
+ target_domain: @target_domain,
93
+ mappings: @mappings,
94
+ mapping_type: @mapping_type,
95
+ strength: @strength,
96
+ structural_score: structural_score,
97
+ surface_score: surface_score,
98
+ similarity_score: similarity_score,
99
+ state: state,
100
+ times_used: @times_used,
101
+ created_at: @created_at,
102
+ last_used_at: @last_used_at
103
+ }
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module AnalogicalReasoning
6
+ module Runners
7
+ module AnalogicalReasoning
8
+ def create_analogy(source_domain:, target_domain:, mappings:, mapping_type: :relational, strength: nil, **)
9
+ unless Helpers::Constants::MAPPING_TYPES.include?(mapping_type)
10
+ return { success: false, error: :invalid_mapping_type,
11
+ valid_types: Helpers::Constants::MAPPING_TYPES }
12
+ end
13
+
14
+ analogy = engine.create_analogy(
15
+ source_domain: source_domain,
16
+ target_domain: target_domain,
17
+ mappings: mappings,
18
+ mapping_type: mapping_type,
19
+ strength: strength
20
+ )
21
+
22
+ Legion::Logging.debug "[analogical_reasoning] created analogy id=#{analogy.id[0..7]} " \
23
+ "#{source_domain}->#{target_domain} type=#{mapping_type}"
24
+
25
+ { success: true, analogy_id: analogy.id, source_domain: source_domain,
26
+ target_domain: target_domain, mapping_type: mapping_type,
27
+ strength: analogy.strength, state: analogy.state }
28
+ end
29
+
30
+ def find_analogies(domain:, **)
31
+ analogies = engine.find_analogies(domain: domain)
32
+ Legion::Logging.debug "[analogical_reasoning] find domain=#{domain} count=#{analogies.size}"
33
+ { success: true, domain: domain, analogies: analogies.map(&:to_h), count: analogies.size }
34
+ end
35
+
36
+ def apply_analogy(analogy_id:, source_element:, **)
37
+ result = engine.apply_analogy(analogy_id: analogy_id, source_element: source_element)
38
+
39
+ Legion::Logging.debug "[analogical_reasoning] apply id=#{analogy_id[0..7]} " \
40
+ "element=#{source_element} mapped=#{result[:mapped]}"
41
+
42
+ { success: true }.merge(result)
43
+ end
44
+
45
+ def evaluate_similarity(source:, target:, **)
46
+ score = engine.evaluate_similarity(source: source, target: target)
47
+ above_threshold = score >= Helpers::Constants::SIMILARITY_THRESHOLD
48
+
49
+ Legion::Logging.debug "[analogical_reasoning] similarity=#{score.round(3)} " \
50
+ "above_threshold=#{above_threshold}"
51
+
52
+ { success: true, similarity_score: score, above_threshold: above_threshold,
53
+ threshold: Helpers::Constants::SIMILARITY_THRESHOLD }
54
+ end
55
+
56
+ def cross_domain_transfer(analogy_id:, source_knowledge:, **)
57
+ result = engine.cross_domain_transfer(analogy_id: analogy_id, source_knowledge: source_knowledge)
58
+
59
+ Legion::Logging.debug "[analogical_reasoning] transfer id=#{analogy_id[0..7]} " \
60
+ "transferred=#{result[:transferred]} coverage=#{result[:coverage]&.round(2)}"
61
+
62
+ { success: true }.merge(result)
63
+ end
64
+
65
+ def reinforce_analogy(analogy_id:, success:, **)
66
+ result = engine.reinforce_analogy(analogy_id: analogy_id, success: success)
67
+
68
+ Legion::Logging.debug "[analogical_reasoning] reinforce id=#{analogy_id[0..7]} " \
69
+ "success=#{success} new_state=#{result[:state]}"
70
+
71
+ { success: true }.merge(result)
72
+ end
73
+
74
+ def productive_analogies(**)
75
+ analogies = engine.productive_analogies
76
+ Legion::Logging.debug "[analogical_reasoning] productive count=#{analogies.size}"
77
+ { success: true, analogies: analogies.map(&:to_h), count: analogies.size }
78
+ end
79
+
80
+ def update_analogical_reasoning(**)
81
+ engine.decay_all
82
+ pruned = engine.prune_stale
83
+ Legion::Logging.debug "[analogical_reasoning] decay+prune pruned=#{pruned}"
84
+ { success: true, pruned: pruned }
85
+ end
86
+
87
+ def analogical_reasoning_stats(**)
88
+ stats = engine.to_h
89
+ Legion::Logging.debug "[analogical_reasoning] stats total=#{stats[:total_analogies]}"
90
+ { success: true }.merge(stats)
91
+ end
92
+
93
+ private
94
+
95
+ def engine
96
+ @engine ||= Helpers::AnalogyEngine.new
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module AnalogicalReasoning
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/analogical_reasoning/version'
4
+ require 'legion/extensions/analogical_reasoning/helpers/constants'
5
+ require 'legion/extensions/analogical_reasoning/helpers/structure_map'
6
+ require 'legion/extensions/analogical_reasoning/helpers/analogy_engine'
7
+ require 'legion/extensions/analogical_reasoning/runners/analogical_reasoning'
8
+ require 'legion/extensions/analogical_reasoning/client'
9
+
10
+ module Legion
11
+ module Extensions
12
+ module AnalogicalReasoning
13
+ extend Legion::Extensions::Core if Legion::Extensions.const_defined? :Core
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/analogical_reasoning/client'
4
+
5
+ RSpec.describe Legion::Extensions::AnalogicalReasoning::Client do
6
+ it 'responds to all runner methods' do
7
+ client = described_class.new
8
+ expect(client).to respond_to(:create_analogy)
9
+ expect(client).to respond_to(:find_analogies)
10
+ expect(client).to respond_to(:apply_analogy)
11
+ expect(client).to respond_to(:evaluate_similarity)
12
+ expect(client).to respond_to(:cross_domain_transfer)
13
+ expect(client).to respond_to(:reinforce_analogy)
14
+ expect(client).to respond_to(:productive_analogies)
15
+ expect(client).to respond_to(:update_analogical_reasoning)
16
+ expect(client).to respond_to(:analogical_reasoning_stats)
17
+ end
18
+
19
+ it 'accepts an injected engine' do
20
+ custom_engine = Legion::Extensions::AnalogicalReasoning::Helpers::AnalogyEngine.new
21
+ client = described_class.new(engine: custom_engine)
22
+ expect(client).to be_a(described_class)
23
+ end
24
+
25
+ it 'creates a default engine when none provided' do
26
+ client = described_class.new
27
+ result = client.analogical_reasoning_stats
28
+ expect(result[:success]).to be true
29
+ expect(result[:total_analogies]).to eq(0)
30
+ end
31
+ end