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.
@@ -0,0 +1,276 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/analogical_reasoning/helpers/analogy_engine'
4
+
5
+ RSpec.describe Legion::Extensions::AnalogicalReasoning::Helpers::AnalogyEngine do
6
+ subject(:engine) { described_class.new }
7
+
8
+ let(:base_analogy_params) do
9
+ {
10
+ source_domain: :solar_system,
11
+ target_domain: :atom,
12
+ mappings: { sun: :nucleus, planet: :electron },
13
+ mapping_type: :relational
14
+ }
15
+ end
16
+
17
+ describe '#create_analogy' do
18
+ it 'creates and returns a StructureMap' do
19
+ analogy = engine.create_analogy(**base_analogy_params)
20
+ expect(analogy).to be_a(Legion::Extensions::AnalogicalReasoning::Helpers::StructureMap)
21
+ end
22
+
23
+ it 'assigns correct domains' do
24
+ analogy = engine.create_analogy(**base_analogy_params)
25
+ expect(analogy.source_domain).to eq(:solar_system)
26
+ expect(analogy.target_domain).to eq(:atom)
27
+ end
28
+
29
+ it 'records the analogy in history' do
30
+ engine.create_analogy(**base_analogy_params)
31
+ expect(engine.history.last[:event]).to eq(:created)
32
+ end
33
+
34
+ it 'stores the analogy for retrieval' do
35
+ analogy = engine.create_analogy(**base_analogy_params)
36
+ result = engine.apply_analogy(analogy_id: analogy.id, source_element: :sun)
37
+ expect(result[:found]).to be true
38
+ end
39
+ end
40
+
41
+ describe '#find_analogies' do
42
+ it 'finds analogies by source domain' do
43
+ engine.create_analogy(**base_analogy_params)
44
+ results = engine.find_analogies(domain: :solar_system)
45
+ expect(results.size).to eq(1)
46
+ end
47
+
48
+ it 'finds analogies by target domain' do
49
+ engine.create_analogy(**base_analogy_params)
50
+ results = engine.find_analogies(domain: :atom)
51
+ expect(results.size).to eq(1)
52
+ end
53
+
54
+ it 'returns empty array when domain not found' do
55
+ results = engine.find_analogies(domain: :unknown)
56
+ expect(results).to be_empty
57
+ end
58
+
59
+ it 'finds across multiple analogies' do
60
+ engine.create_analogy(**base_analogy_params)
61
+ engine.create_analogy(
62
+ source_domain: :solar_system,
63
+ target_domain: :family,
64
+ mappings: { sun: :parent },
65
+ mapping_type: :relational
66
+ )
67
+ results = engine.find_analogies(domain: :solar_system)
68
+ expect(results.size).to eq(2)
69
+ end
70
+ end
71
+
72
+ describe '#apply_analogy' do
73
+ let(:analogy) { engine.create_analogy(**base_analogy_params) }
74
+
75
+ it 'returns mapped element when found' do
76
+ result = engine.apply_analogy(analogy_id: analogy.id, source_element: :sun)
77
+ expect(result[:found]).to be true
78
+ expect(result[:mapped]).to be true
79
+ expect(result[:target_element]).to eq(:nucleus)
80
+ end
81
+
82
+ it 'returns mapped: false when source element has no mapping' do
83
+ result = engine.apply_analogy(analogy_id: analogy.id, source_element: :comet)
84
+ expect(result[:found]).to be true
85
+ expect(result[:mapped]).to be false
86
+ end
87
+
88
+ it 'returns found: false for unknown analogy_id' do
89
+ result = engine.apply_analogy(analogy_id: 'nonexistent', source_element: :sun)
90
+ expect(result[:found]).to be false
91
+ end
92
+
93
+ it 'includes confidence in result' do
94
+ result = engine.apply_analogy(analogy_id: analogy.id, source_element: :sun)
95
+ expect(result[:confidence]).to be_a(Float)
96
+ end
97
+
98
+ it 'records apply event in history' do
99
+ engine.apply_analogy(analogy_id: analogy.id, source_element: :sun)
100
+ applied = engine.history.select { |h| h[:event] == :applied }
101
+ expect(applied).not_to be_empty
102
+ end
103
+ end
104
+
105
+ describe '#evaluate_similarity' do
106
+ it 'returns 1.0 for identical concept hashes' do
107
+ concept = { color: :red, shape: :round }
108
+ score = engine.evaluate_similarity(source: concept, target: concept)
109
+ expect(score).to be > 0.8
110
+ end
111
+
112
+ it 'returns 0.0 for empty hashes' do
113
+ expect(engine.evaluate_similarity(source: {}, target: {})).to eq(0.0)
114
+ end
115
+
116
+ it 'returns lower score for dissimilar concepts' do
117
+ source = { color: :red, shape: :round, size: :large }
118
+ target = { color: :blue, shape: :square, weight: :heavy }
119
+ score = engine.evaluate_similarity(source: source, target: target)
120
+ expect(score).to be < 0.5
121
+ end
122
+
123
+ it 'returns higher score for structurally similar concepts' do
124
+ source = { role: :attractor, relation: :orbits, parent: :center }
125
+ target = { role: :attractor, relation: :orbits, parent: :core }
126
+ score = engine.evaluate_similarity(source: source, target: target)
127
+ expect(score).to be > 0.4
128
+ end
129
+ end
130
+
131
+ describe '#cross_domain_transfer' do
132
+ let(:analogy) { engine.create_analogy(**base_analogy_params) }
133
+
134
+ it 'maps source knowledge to target domain' do
135
+ result = engine.cross_domain_transfer(
136
+ analogy_id: analogy.id,
137
+ source_knowledge: { sun: 'massive gravity well' }
138
+ )
139
+ expect(result[:transferred]).to be true
140
+ expect(result[:result][:nucleus]).to eq('massive gravity well')
141
+ end
142
+
143
+ it 'returns transferred: false for unknown analogy' do
144
+ result = engine.cross_domain_transfer(analogy_id: 'nope', source_knowledge: {})
145
+ expect(result[:transferred]).to be false
146
+ end
147
+
148
+ it 'includes coverage ratio' do
149
+ result = engine.cross_domain_transfer(
150
+ analogy_id: analogy.id,
151
+ source_knowledge: { sun: 'center', comet: 'visitor' }
152
+ )
153
+ expect(result[:coverage]).to eq(0.5)
154
+ end
155
+
156
+ it 'records transfer event in history' do
157
+ engine.cross_domain_transfer(analogy_id: analogy.id, source_knowledge: { sun: 'data' })
158
+ transferred = engine.history.select { |h| h[:event] == :transferred }
159
+ expect(transferred).not_to be_empty
160
+ end
161
+ end
162
+
163
+ describe '#reinforce_analogy' do
164
+ let(:analogy) { engine.create_analogy(**base_analogy_params) }
165
+
166
+ it 'strengthens analogy on success' do
167
+ initial = analogy.strength
168
+ engine.reinforce_analogy(analogy_id: analogy.id, success: true)
169
+ expect(analogy.strength).to be > initial
170
+ end
171
+
172
+ it 'weakens analogy on failure' do
173
+ initial = analogy.strength
174
+ engine.reinforce_analogy(analogy_id: analogy.id, success: false)
175
+ expect(analogy.strength).to be < initial
176
+ end
177
+
178
+ it 'returns not_found for unknown analogy' do
179
+ result = engine.reinforce_analogy(analogy_id: 'nope', success: true)
180
+ expect(result[:reinforced]).to be false
181
+ end
182
+
183
+ it 'returns reinforced: true on success' do
184
+ result = engine.reinforce_analogy(analogy_id: analogy.id, success: true)
185
+ expect(result[:reinforced]).to be true
186
+ end
187
+ end
188
+
189
+ describe '#productive_analogies' do
190
+ it 'returns only productive analogies' do
191
+ strong_analogy = engine.create_analogy(
192
+ source_domain: :water,
193
+ target_domain: :electricity,
194
+ mappings: { pressure: :voltage },
195
+ mapping_type: :relational,
196
+ strength: 0.9
197
+ )
198
+ engine.create_analogy(**base_analogy_params)
199
+ productive = engine.productive_analogies
200
+ expect(productive.map(&:id)).to include(strong_analogy.id)
201
+ end
202
+
203
+ it 'returns empty array when no productive analogies' do
204
+ engine.create_analogy(**base_analogy_params)
205
+ expect(engine.productive_analogies).to be_empty
206
+ end
207
+ end
208
+
209
+ describe '#by_domain' do
210
+ it 'delegates to find_analogies' do
211
+ engine.create_analogy(**base_analogy_params)
212
+ results = engine.by_domain(domain: :solar_system)
213
+ expect(results.size).to eq(1)
214
+ end
215
+ end
216
+
217
+ describe '#by_type' do
218
+ it 'filters analogies by mapping type' do
219
+ engine.create_analogy(**base_analogy_params)
220
+ engine.create_analogy(
221
+ source_domain: :water,
222
+ target_domain: :electricity,
223
+ mappings: { pressure: :voltage },
224
+ mapping_type: :attribute
225
+ )
226
+ results = engine.by_type(type: :relational)
227
+ expect(results.all? { |analogy| analogy.mapping_type == :relational }).to be true
228
+ end
229
+ end
230
+
231
+ describe '#decay_all' do
232
+ it 'reduces strength of all analogies' do
233
+ analogy = engine.create_analogy(**base_analogy_params)
234
+ initial = analogy.strength
235
+ engine.decay_all
236
+ expect(analogy.strength).to be < initial
237
+ end
238
+ end
239
+
240
+ describe '#prune_stale' do
241
+ it 'removes stale analogies and returns count' do
242
+ engine.create_analogy(
243
+ source_domain: :old, target_domain: :domain, mappings: {}, mapping_type: :attribute,
244
+ strength: 0.06
245
+ )
246
+ engine.create_analogy(**base_analogy_params)
247
+
248
+ pruned = engine.prune_stale
249
+ expect(pruned).to eq(1)
250
+ expect(engine.find_analogies(domain: :old)).to be_empty
251
+ end
252
+
253
+ it 'returns 0 when no stale analogies' do
254
+ engine.create_analogy(**base_analogy_params, strength: 0.9)
255
+ expect(engine.prune_stale).to eq(0)
256
+ end
257
+ end
258
+
259
+ describe '#to_h' do
260
+ it 'returns stats hash with expected keys' do
261
+ engine.create_analogy(**base_analogy_params)
262
+ stats = engine.to_h
263
+ expect(stats).to have_key(:total_analogies)
264
+ expect(stats).to have_key(:total_domains)
265
+ expect(stats).to have_key(:productive_count)
266
+ expect(stats).to have_key(:history_count)
267
+ expect(stats).to have_key(:domains)
268
+ expect(stats).to have_key(:analogy_states)
269
+ end
270
+
271
+ it 'counts domains correctly' do
272
+ engine.create_analogy(**base_analogy_params)
273
+ expect(engine.to_h[:total_domains]).to eq(2)
274
+ end
275
+ end
276
+ end
@@ -0,0 +1,255 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/analogical_reasoning/helpers/structure_map'
4
+
5
+ RSpec.describe Legion::Extensions::AnalogicalReasoning::Helpers::StructureMap do
6
+ let(:relational_mappings) do
7
+ {
8
+ sun: { type: :relational, maps_to: :nucleus },
9
+ orbit: { type: :relational, maps_to: :electron_path }
10
+ }
11
+ end
12
+
13
+ let(:surface_mappings) do
14
+ {
15
+ yellow: :gold,
16
+ hot: :energetic
17
+ }
18
+ end
19
+
20
+ let(:structure_map) do
21
+ described_class.new(
22
+ source_domain: :solar_system,
23
+ target_domain: :atom,
24
+ mappings: relational_mappings,
25
+ mapping_type: :relational
26
+ )
27
+ end
28
+
29
+ describe '#initialize' do
30
+ it 'assigns required attributes' do
31
+ expect(structure_map.source_domain).to eq(:solar_system)
32
+ expect(structure_map.target_domain).to eq(:atom)
33
+ expect(structure_map.mapping_type).to eq(:relational)
34
+ expect(structure_map.mappings).to eq(relational_mappings)
35
+ end
36
+
37
+ it 'generates a UUID id' do
38
+ expect(structure_map.id).to match(/\A[0-9a-f-]{36}\z/)
39
+ end
40
+
41
+ it 'sets default strength' do
42
+ expect(structure_map.strength).to eq(Legion::Extensions::AnalogicalReasoning::Helpers::Constants::DEFAULT_STRENGTH)
43
+ end
44
+
45
+ it 'clamps custom strength to ceiling' do
46
+ map = described_class.new(
47
+ source_domain: :a,
48
+ target_domain: :b,
49
+ mappings: {},
50
+ mapping_type: :attribute,
51
+ strength: 2.0
52
+ )
53
+ expect(map.strength).to eq(Legion::Extensions::AnalogicalReasoning::Helpers::Constants::STRENGTH_CEILING)
54
+ end
55
+
56
+ it 'clamps custom strength to floor' do
57
+ map = described_class.new(
58
+ source_domain: :a,
59
+ target_domain: :b,
60
+ mappings: {},
61
+ mapping_type: :attribute,
62
+ strength: 0.0
63
+ )
64
+ expect(map.strength).to eq(Legion::Extensions::AnalogicalReasoning::Helpers::Constants::STRENGTH_FLOOR)
65
+ end
66
+
67
+ it 'sets times_used to 0' do
68
+ expect(structure_map.times_used).to eq(0)
69
+ end
70
+ end
71
+
72
+ describe '#structural_score' do
73
+ it 'returns proportion of relational mappings' do
74
+ expect(structure_map.structural_score).to eq(1.0)
75
+ end
76
+
77
+ it 'returns 0.0 for empty mappings' do
78
+ map = described_class.new(
79
+ source_domain: :a, target_domain: :b, mappings: {}, mapping_type: :attribute
80
+ )
81
+ expect(map.structural_score).to eq(0.0)
82
+ end
83
+
84
+ it 'returns partial score for mixed mappings' do
85
+ mixed = relational_mappings.merge(surface_mappings)
86
+ map = described_class.new(
87
+ source_domain: :a, target_domain: :b, mappings: mixed, mapping_type: :relational
88
+ )
89
+ expect(map.structural_score).to eq(0.5)
90
+ end
91
+ end
92
+
93
+ describe '#surface_score' do
94
+ it 'returns proportion of non-relational mappings' do
95
+ map = described_class.new(
96
+ source_domain: :a, target_domain: :b, mappings: surface_mappings, mapping_type: :attribute
97
+ )
98
+ expect(map.surface_score).to eq(1.0)
99
+ end
100
+
101
+ it 'returns 0.0 for empty mappings' do
102
+ map = described_class.new(
103
+ source_domain: :a, target_domain: :b, mappings: {}, mapping_type: :attribute
104
+ )
105
+ expect(map.surface_score).to eq(0.0)
106
+ end
107
+ end
108
+
109
+ describe '#similarity_score' do
110
+ it 'weights structural and surface scores correctly' do
111
+ map = described_class.new(
112
+ source_domain: :a, target_domain: :b, mappings: relational_mappings, mapping_type: :relational
113
+ )
114
+ expected = (0.7 * 1.0) + (0.3 * 0.0)
115
+ expect(map.similarity_score).to be_within(0.001).of(expected)
116
+ end
117
+ end
118
+
119
+ describe '#use!' do
120
+ it 'increments times_used' do
121
+ structure_map.use!
122
+ expect(structure_map.times_used).to eq(1)
123
+ end
124
+
125
+ it 'updates last_used_at' do
126
+ before = structure_map.last_used_at
127
+ sleep 0.01
128
+ structure_map.use!
129
+ expect(structure_map.last_used_at).to be >= before
130
+ end
131
+
132
+ it 'boosts strength' do
133
+ initial = structure_map.strength
134
+ structure_map.use!
135
+ expect(structure_map.strength).to be > initial
136
+ end
137
+
138
+ it 'returns self' do
139
+ expect(structure_map.use!).to eq(structure_map)
140
+ end
141
+ end
142
+
143
+ describe '#reinforce' do
144
+ it 'increases strength by reinforcement rate' do
145
+ initial = structure_map.strength
146
+ structure_map.reinforce
147
+ expect(structure_map.strength).to be_within(0.001).of(
148
+ (initial + Legion::Extensions::AnalogicalReasoning::Helpers::Constants::REINFORCEMENT_RATE)
149
+ .clamp(
150
+ Legion::Extensions::AnalogicalReasoning::Helpers::Constants::STRENGTH_FLOOR,
151
+ Legion::Extensions::AnalogicalReasoning::Helpers::Constants::STRENGTH_CEILING
152
+ )
153
+ )
154
+ end
155
+
156
+ it 'does not exceed strength ceiling' do
157
+ map = described_class.new(
158
+ source_domain: :a, target_domain: :b, mappings: {}, mapping_type: :attribute,
159
+ strength: Legion::Extensions::AnalogicalReasoning::Helpers::Constants::STRENGTH_CEILING
160
+ )
161
+ map.reinforce(amount: 0.5)
162
+ expect(map.strength).to eq(Legion::Extensions::AnalogicalReasoning::Helpers::Constants::STRENGTH_CEILING)
163
+ end
164
+ end
165
+
166
+ describe '#weaken' do
167
+ it 'decreases strength' do
168
+ initial = structure_map.strength
169
+ structure_map.weaken
170
+ expect(structure_map.strength).to be < initial
171
+ end
172
+
173
+ it 'does not drop below strength floor' do
174
+ map = described_class.new(
175
+ source_domain: :a, target_domain: :b, mappings: {}, mapping_type: :attribute,
176
+ strength: Legion::Extensions::AnalogicalReasoning::Helpers::Constants::STRENGTH_FLOOR
177
+ )
178
+ map.weaken(amount: 0.5)
179
+ expect(map.strength).to eq(Legion::Extensions::AnalogicalReasoning::Helpers::Constants::STRENGTH_FLOOR)
180
+ end
181
+ end
182
+
183
+ describe '#decay' do
184
+ it 'reduces strength by decay rate' do
185
+ initial = structure_map.strength
186
+ structure_map.decay
187
+ expect(structure_map.strength).to be_within(0.001).of(
188
+ (initial - Legion::Extensions::AnalogicalReasoning::Helpers::Constants::DECAY_RATE)
189
+ .clamp(
190
+ Legion::Extensions::AnalogicalReasoning::Helpers::Constants::STRENGTH_FLOOR,
191
+ Legion::Extensions::AnalogicalReasoning::Helpers::Constants::STRENGTH_CEILING
192
+ )
193
+ )
194
+ end
195
+ end
196
+
197
+ describe '#state' do
198
+ it 'returns :productive when strength is high' do
199
+ map = described_class.new(
200
+ source_domain: :a, target_domain: :b, mappings: {}, mapping_type: :attribute, strength: 0.9
201
+ )
202
+ expect(map.state).to eq(:productive)
203
+ end
204
+
205
+ it 'returns :validated for mid-range strength' do
206
+ map = described_class.new(
207
+ source_domain: :a, target_domain: :b, mappings: {}, mapping_type: :attribute, strength: 0.6
208
+ )
209
+ expect(map.state).to eq(:validated)
210
+ end
211
+
212
+ it 'returns :candidate for low-mid strength' do
213
+ map = described_class.new(
214
+ source_domain: :a, target_domain: :b, mappings: {}, mapping_type: :attribute, strength: 0.35
215
+ )
216
+ expect(map.state).to eq(:candidate)
217
+ end
218
+
219
+ it 'returns :stale for very low strength' do
220
+ map = described_class.new(
221
+ source_domain: :a, target_domain: :b, mappings: {}, mapping_type: :attribute, strength: 0.1
222
+ )
223
+ expect(map.state).to eq(:stale)
224
+ end
225
+ end
226
+
227
+ describe '#productive?' do
228
+ it 'returns true when state is productive' do
229
+ map = described_class.new(
230
+ source_domain: :a, target_domain: :b, mappings: {}, mapping_type: :attribute, strength: 0.9
231
+ )
232
+ expect(map.productive?).to be true
233
+ end
234
+
235
+ it 'returns false when state is not productive' do
236
+ expect(structure_map.productive?).to be false
237
+ end
238
+ end
239
+
240
+ describe '#to_h' do
241
+ it 'includes all expected keys' do
242
+ hash = structure_map.to_h
243
+ expected_keys = %i[id source_domain target_domain mappings mapping_type strength
244
+ structural_score surface_score similarity_score state
245
+ times_used created_at last_used_at]
246
+ expected_keys.each { |key| expect(hash).to have_key(key) }
247
+ end
248
+
249
+ it 'reflects current state' do
250
+ hash = structure_map.to_h
251
+ expect(hash[:source_domain]).to eq(:solar_system)
252
+ expect(hash[:target_domain]).to eq(:atom)
253
+ end
254
+ end
255
+ end