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 +7 -0
- data/Gemfile +11 -0
- data/lex-analogical-reasoning.gemspec +30 -0
- data/lib/legion/extensions/analogical_reasoning/client.rb +24 -0
- data/lib/legion/extensions/analogical_reasoning/helpers/analogy_engine.rb +205 -0
- data/lib/legion/extensions/analogical_reasoning/helpers/constants.rb +34 -0
- data/lib/legion/extensions/analogical_reasoning/helpers/structure_map.rb +109 -0
- data/lib/legion/extensions/analogical_reasoning/runners/analogical_reasoning.rb +102 -0
- data/lib/legion/extensions/analogical_reasoning/version.rb +9 -0
- data/lib/legion/extensions/analogical_reasoning.rb +16 -0
- data/spec/legion/extensions/analogical_reasoning/client_spec.rb +31 -0
- data/spec/legion/extensions/analogical_reasoning/helpers/analogy_engine_spec.rb +276 -0
- data/spec/legion/extensions/analogical_reasoning/helpers/structure_map_spec.rb +255 -0
- data/spec/legion/extensions/analogical_reasoning/runners/analogical_reasoning_spec.rb +213 -0
- data/spec/spec_helper.rb +20 -0
- metadata +75 -0
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,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,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
|