lex-meta-learning 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,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::MetaLearning::Helpers::LearningDomain do
4
+ subject(:domain) { described_class.new(name: 'ruby') }
5
+
6
+ describe '#initialize' do
7
+ it 'sets a uuid id' do
8
+ expect(domain.id).to match(/\A[0-9a-f-]{36}\z/)
9
+ end
10
+
11
+ it 'sets name' do
12
+ expect(domain.name).to eq('ruby')
13
+ end
14
+
15
+ it 'starts with zero proficiency' do
16
+ expect(domain.proficiency).to eq(0.0)
17
+ end
18
+
19
+ it 'uses default learning rate' do
20
+ expect(domain.learning_rate).to eq(0.1)
21
+ end
22
+
23
+ it 'starts with zero successes and failures' do
24
+ expect(domain.successes).to eq(0)
25
+ expect(domain.failures).to eq(0)
26
+ end
27
+
28
+ it 'accepts custom learning rate' do
29
+ d = described_class.new(name: 'go', learning_rate: 0.3)
30
+ expect(d.learning_rate).to eq(0.3)
31
+ end
32
+
33
+ it 'accepts related_domains' do
34
+ d = described_class.new(name: 'go', related_domains: ['ruby'])
35
+ expect(d.related_domains).to include('ruby')
36
+ end
37
+ end
38
+
39
+ describe '#record_success!' do
40
+ it 'increments successes and episodes_count' do
41
+ domain.record_success!
42
+ expect(domain.successes).to eq(1)
43
+ expect(domain.episodes_count).to eq(1)
44
+ end
45
+
46
+ it 'increases proficiency by learning_rate' do
47
+ domain.record_success!
48
+ expect(domain.proficiency).to be_within(0.0001).of(0.1)
49
+ end
50
+
51
+ it 'does not exceed proficiency of 1.0' do
52
+ 15.times { domain.record_success! }
53
+ expect(domain.proficiency).to be <= 1.0
54
+ end
55
+ end
56
+
57
+ describe '#record_failure!' do
58
+ it 'increments failures and episodes_count' do
59
+ domain.record_failure!
60
+ expect(domain.failures).to eq(1)
61
+ expect(domain.episodes_count).to eq(1)
62
+ end
63
+
64
+ it 'does not drop proficiency below 0.0' do
65
+ domain.record_failure!
66
+ expect(domain.proficiency).to be >= 0.0
67
+ end
68
+
69
+ it 'decreases proficiency when above zero' do
70
+ 5.times { domain.record_success! }
71
+ proficiency_before = domain.proficiency
72
+ domain.record_failure!
73
+ expect(domain.proficiency).to be < proficiency_before
74
+ end
75
+ end
76
+
77
+ describe '#efficiency' do
78
+ it 'returns 0.0 with no episodes' do
79
+ expect(domain.efficiency).to eq(0.0)
80
+ end
81
+
82
+ it 'returns 1.0 with all successes' do
83
+ 3.times { domain.record_success! }
84
+ expect(domain.efficiency).to eq(1.0)
85
+ end
86
+
87
+ it 'returns 0.5 with equal successes and failures' do
88
+ 2.times { domain.record_success! }
89
+ 2.times { domain.record_failure! }
90
+ expect(domain.efficiency).to eq(0.5)
91
+ end
92
+ end
93
+
94
+ describe '#efficiency_label' do
95
+ it 'returns :struggling for 0.0 efficiency' do
96
+ expect(domain.efficiency_label).to eq(:struggling)
97
+ end
98
+
99
+ it 'returns :highly_efficient for 1.0 efficiency' do
100
+ 3.times { domain.record_success! }
101
+ expect(domain.efficiency_label).to eq(:highly_efficient)
102
+ end
103
+ end
104
+
105
+ describe '#proficiency_label' do
106
+ it 'returns :beginner for new domain' do
107
+ expect(domain.proficiency_label).to eq(:beginner)
108
+ end
109
+
110
+ it 'returns :expert when proficiency >= 0.8' do
111
+ d = described_class.new(name: 'test', learning_rate: 0.9)
112
+ d.record_success!
113
+ expect(d.proficiency_label).to eq(:expert)
114
+ end
115
+ end
116
+
117
+ describe '#adapt_rate!' do
118
+ it 'increases learning rate with positive delta' do
119
+ domain.adapt_rate!(delta: 0.05)
120
+ expect(domain.learning_rate).to be_within(0.0001).of(0.15)
121
+ end
122
+
123
+ it 'decreases learning rate with negative delta' do
124
+ domain.adapt_rate!(delta: -0.05)
125
+ expect(domain.learning_rate).to be_within(0.0001).of(0.05)
126
+ end
127
+
128
+ it 'clamps rate to minimum 0.001' do
129
+ domain.adapt_rate!(delta: -999.0)
130
+ expect(domain.learning_rate).to eq(0.001)
131
+ end
132
+
133
+ it 'clamps rate to maximum 1.0' do
134
+ domain.adapt_rate!(delta: 999.0)
135
+ expect(domain.learning_rate).to eq(1.0)
136
+ end
137
+ end
138
+
139
+ describe '#to_h' do
140
+ it 'returns a hash with all expected keys' do
141
+ h = domain.to_h
142
+ expect(h).to include(:id, :name, :proficiency, :learning_rate, :successes, :failures,
143
+ :efficiency, :proficiency_label, :efficiency_label, :related_domains)
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,309 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::MetaLearning::Helpers::MetaLearningEngine do
4
+ subject(:engine) { described_class.new }
5
+
6
+ let(:ruby_domain) { engine.create_domain(name: 'ruby') }
7
+ let(:python_domain) { engine.create_domain(name: 'python', related_domains: ['ruby']) }
8
+ let(:flash_strategy) do
9
+ engine.create_strategy(name: 'flash_cards', strategy_type: :repetition)
10
+ end
11
+
12
+ describe '#create_domain' do
13
+ it 'returns a LearningDomain' do
14
+ expect(ruby_domain).to be_a(Legion::Extensions::MetaLearning::Helpers::LearningDomain)
15
+ end
16
+
17
+ it 'stores the domain by id' do
18
+ d = ruby_domain
19
+ expect(engine.domains[d.id]).to eq(d)
20
+ end
21
+
22
+ it 'returns error hash when limit reached' do
23
+ stub_const('Legion::Extensions::MetaLearning::Helpers::Constants::MAX_DOMAINS', 0)
24
+ result = engine.create_domain(name: 'test')
25
+ expect(result[:error]).to eq(:limit_reached)
26
+ end
27
+ end
28
+
29
+ describe '#create_strategy' do
30
+ it 'returns a Strategy' do
31
+ expect(flash_strategy).to be_a(Legion::Extensions::MetaLearning::Helpers::Strategy)
32
+ end
33
+
34
+ it 'returns error for invalid strategy_type' do
35
+ result = engine.create_strategy(name: 'bad', strategy_type: :nonexistent)
36
+ expect(result[:error]).to eq(:invalid_strategy_type)
37
+ end
38
+
39
+ it 'returns error when limit reached' do
40
+ stub_const('Legion::Extensions::MetaLearning::Helpers::Constants::MAX_STRATEGIES', 0)
41
+ result = engine.create_strategy(name: 'test', strategy_type: :repetition)
42
+ expect(result[:error]).to eq(:limit_reached)
43
+ end
44
+ end
45
+
46
+ describe '#record_episode' do
47
+ context 'with valid domain' do
48
+ it 'records a successful episode' do
49
+ d = ruby_domain
50
+ result = engine.record_episode(domain_id: d.id, success: true)
51
+ expect(result[:success]).to be true
52
+ expect(result[:domain_name]).to eq('ruby')
53
+ end
54
+
55
+ it 'records a failure episode' do
56
+ d = ruby_domain
57
+ result = engine.record_episode(domain_id: d.id, success: false)
58
+ expect(result[:success]).to be false
59
+ end
60
+
61
+ it 'updates domain proficiency on success' do
62
+ d = ruby_domain
63
+ engine.record_episode(domain_id: d.id, success: true)
64
+ expect(d.proficiency).to be > 0.0
65
+ end
66
+
67
+ it 'appends to episodes array' do
68
+ d = ruby_domain
69
+ engine.record_episode(domain_id: d.id, success: true)
70
+ expect(engine.episodes.size).to eq(1)
71
+ end
72
+
73
+ it 'records strategy usage when strategy_id provided' do
74
+ d = ruby_domain
75
+ s = flash_strategy
76
+ engine.record_episode(domain_id: d.id, strategy_id: s.id, success: true)
77
+ expect(s.usage_count).to eq(1)
78
+ end
79
+
80
+ it 'sets preferred_strategy after successful use' do
81
+ d = ruby_domain
82
+ s = flash_strategy
83
+ engine.record_episode(domain_id: d.id, strategy_id: s.id, success: true)
84
+ expect(d.preferred_strategy).to eq('flash_cards')
85
+ end
86
+ end
87
+
88
+ context 'with invalid domain' do
89
+ it 'returns error hash' do
90
+ result = engine.record_episode(domain_id: 'bad-id', success: true)
91
+ expect(result[:error]).to eq(:domain_not_found)
92
+ end
93
+ end
94
+ end
95
+
96
+ describe '#recommend_strategy' do
97
+ it 'returns error for unknown domain' do
98
+ result = engine.recommend_strategy(domain_id: 'bad')
99
+ expect(result[:error]).to eq(:domain_not_found)
100
+ end
101
+
102
+ it 'returns no_data when no strategies used' do
103
+ d = ruby_domain
104
+ result = engine.recommend_strategy(domain_id: d.id)
105
+ expect(result[:reason]).to eq(:no_data)
106
+ end
107
+
108
+ it 'recommends the best performing strategy' do
109
+ d = ruby_domain
110
+ s1 = engine.create_strategy(name: 'cards', strategy_type: :repetition)
111
+ s2 = engine.create_strategy(name: 'elaboration', strategy_type: :elaboration)
112
+ 3.times { engine.record_episode(domain_id: d.id, strategy_id: s1.id, success: true) }
113
+ engine.record_episode(domain_id: d.id, strategy_id: s2.id, success: false)
114
+ result = engine.recommend_strategy(domain_id: d.id)
115
+ expect(result[:recommendation]).to eq('cards')
116
+ end
117
+
118
+ it 'recommends via related domains when no direct data' do
119
+ d_ruby = ruby_domain
120
+ d_python = python_domain
121
+ s = flash_strategy
122
+ 3.times { engine.record_episode(domain_id: d_ruby.id, strategy_id: s.id, success: true) }
123
+ result = engine.recommend_strategy(domain_id: d_python.id)
124
+ expect(result[:recommendation]).to eq('flash_cards')
125
+ end
126
+ end
127
+
128
+ describe '#transfer_check' do
129
+ it 'returns error if domain not found' do
130
+ result = engine.transfer_check(source_domain_id: 'bad', target_domain_id: 'also-bad')
131
+ expect(result[:error]).to eq(:domain_not_found)
132
+ end
133
+
134
+ it 'returns not eligible when source proficiency is low' do
135
+ d_ruby = ruby_domain
136
+ d_python = python_domain
137
+ result = engine.transfer_check(source_domain_id: d_ruby.id, target_domain_id: d_python.id)
138
+ expect(result[:eligible]).to be false
139
+ end
140
+
141
+ it 'returns eligible when source is proficient and domains are related' do
142
+ d_ruby = ruby_domain
143
+ d_python = python_domain
144
+ d = described_class.new
145
+ d_ruby_high = d.create_domain(name: 'ruby')
146
+ d_python_rel = d.create_domain(name: 'python', related_domains: ['ruby'])
147
+ 8.times { d.record_episode(domain_id: d_ruby_high.id, success: true) }
148
+ result = d.transfer_check(source_domain_id: d_ruby_high.id, target_domain_id: d_python_rel.id)
149
+ expect(result[:eligible]).to be true
150
+ expect(d_ruby).not_to be_nil
151
+ expect(d_python).not_to be_nil
152
+ end
153
+ end
154
+
155
+ describe '#apply_transfer' do
156
+ it 'returns error if domain not found' do
157
+ result = engine.apply_transfer(source_domain_id: 'bad', target_domain_id: 'also-bad')
158
+ expect(result[:error]).to eq(:domain_not_found)
159
+ end
160
+
161
+ it 'returns not applied when not eligible' do
162
+ d_ruby = ruby_domain
163
+ d_python = python_domain
164
+ result = engine.apply_transfer(source_domain_id: d_ruby.id, target_domain_id: d_python.id)
165
+ expect(result[:applied]).to be false
166
+ end
167
+
168
+ it 'applies transfer bonus to target learning rate' do
169
+ eng = described_class.new
170
+ src = eng.create_domain(name: 'ruby')
171
+ tgt = eng.create_domain(name: 'python', related_domains: ['ruby'])
172
+ 8.times { eng.record_episode(domain_id: src.id, success: true) }
173
+ original_rate = tgt.learning_rate
174
+ result = eng.apply_transfer(source_domain_id: src.id, target_domain_id: tgt.id)
175
+ expect(result[:applied]).to be true
176
+ expect(tgt.learning_rate).to be > original_rate
177
+ end
178
+ end
179
+
180
+ describe '#domain_ranking' do
181
+ it 'returns domains sorted by proficiency descending' do
182
+ d1 = engine.create_domain(name: 'ruby')
183
+ d2 = engine.create_domain(name: 'python')
184
+ 3.times { engine.record_episode(domain_id: d1.id, success: true) }
185
+ engine.record_episode(domain_id: d2.id, success: true)
186
+ ranking = engine.domain_ranking
187
+ expect(ranking.first[:name]).to eq('ruby')
188
+ end
189
+
190
+ it 'respects the limit parameter' do
191
+ 5.times { |i| engine.create_domain(name: "domain_#{i}") }
192
+ expect(engine.domain_ranking(limit: 3).size).to eq(3)
193
+ end
194
+ end
195
+
196
+ describe '#strategy_ranking' do
197
+ it 'returns strategies sorted by success_rate descending' do
198
+ s1 = engine.create_strategy(name: 'good', strategy_type: :repetition)
199
+ s2 = engine.create_strategy(name: 'bad', strategy_type: :elaboration)
200
+ d = ruby_domain
201
+ 3.times { engine.record_episode(domain_id: d.id, strategy_id: s1.id, success: true) }
202
+ engine.record_episode(domain_id: d.id, strategy_id: s2.id, success: false)
203
+ ranking = engine.strategy_ranking
204
+ expect(ranking.first[:name]).to eq('good')
205
+ end
206
+ end
207
+
208
+ describe '#overall_efficiency' do
209
+ it 'returns 0.0 with no domains' do
210
+ expect(engine.overall_efficiency).to eq(0.0)
211
+ end
212
+
213
+ it 'returns average efficiency across domains' do
214
+ d1 = engine.create_domain(name: 'ruby')
215
+ d2 = engine.create_domain(name: 'python')
216
+ 2.times { engine.record_episode(domain_id: d1.id, success: true) }
217
+ 2.times { engine.record_episode(domain_id: d2.id, success: false) }
218
+ expect(engine.overall_efficiency).to eq(0.5)
219
+ end
220
+ end
221
+
222
+ describe '#learning_curve' do
223
+ it 'returns error for unknown domain' do
224
+ result = engine.learning_curve(domain_id: 'bad')
225
+ expect(result[:error]).to eq(:domain_not_found)
226
+ end
227
+
228
+ it 'returns episodes for domain in order' do
229
+ d = ruby_domain
230
+ 3.times { engine.record_episode(domain_id: d.id, success: true) }
231
+ result = engine.learning_curve(domain_id: d.id)
232
+ expect(result[:curve].size).to eq(3)
233
+ expect(result[:domain]).to eq('ruby')
234
+ end
235
+
236
+ it 'includes proficiency snapshots' do
237
+ d = ruby_domain
238
+ engine.record_episode(domain_id: d.id, success: true)
239
+ result = engine.learning_curve(domain_id: d.id)
240
+ expect(result[:curve].first).to have_key(:proficiency)
241
+ end
242
+ end
243
+
244
+ describe '#adapt_rates' do
245
+ it 'boosts rate for highly efficient domain' do
246
+ d = engine.create_domain(name: 'ruby')
247
+ 5.times { engine.record_episode(domain_id: d.id, success: true) }
248
+ original_rate = d.learning_rate
249
+ engine.adapt_rates
250
+ expect(d.learning_rate).to be > original_rate
251
+ end
252
+
253
+ it 'decays rate for struggling domain' do
254
+ d = engine.create_domain(name: 'ruby')
255
+ 5.times { engine.record_episode(domain_id: d.id, success: false) }
256
+ original_rate = d.learning_rate
257
+ engine.adapt_rates
258
+ expect(d.learning_rate).to be < original_rate
259
+ end
260
+
261
+ it 'returns count of adapted domains' do
262
+ d = engine.create_domain(name: 'ruby')
263
+ 5.times { engine.record_episode(domain_id: d.id, success: true) }
264
+ result = engine.adapt_rates
265
+ expect(result[:count]).to eq(1)
266
+ end
267
+
268
+ it 'skips domains with no episodes' do
269
+ engine.create_domain(name: 'virgin')
270
+ result = engine.adapt_rates
271
+ expect(result[:count]).to eq(0)
272
+ end
273
+ end
274
+
275
+ describe '#prune_stale_domains' do
276
+ it 'removes domains with fewer than min_episodes' do
277
+ engine.create_domain(name: 'stale')
278
+ result = engine.prune_stale_domains(min_episodes: 1)
279
+ expect(result[:pruned]).to eq(1)
280
+ expect(result[:remaining]).to eq(0)
281
+ end
282
+
283
+ it 'keeps domains that meet the threshold' do
284
+ d = engine.create_domain(name: 'active')
285
+ engine.record_episode(domain_id: d.id, success: true)
286
+ result = engine.prune_stale_domains(min_episodes: 1)
287
+ expect(result[:pruned]).to eq(0)
288
+ expect(result[:remaining]).to eq(1)
289
+ end
290
+ end
291
+
292
+ describe '#to_h' do
293
+ it 'returns engine stats hash' do
294
+ ruby_domain
295
+ h = engine.to_h
296
+ expect(h).to include(:domain_count, :strategy_count, :episode_count, :overall_efficiency)
297
+ expect(h[:domain_count]).to eq(1)
298
+ end
299
+ end
300
+
301
+ describe 'episode cap' do
302
+ it 'does not exceed MAX_EPISODES' do
303
+ stub_const('Legion::Extensions::MetaLearning::Helpers::Constants::MAX_EPISODES', 3)
304
+ d = engine.create_domain(name: 'ruby')
305
+ 5.times { engine.record_episode(domain_id: d.id, success: true) }
306
+ expect(engine.episodes.size).to eq(3)
307
+ end
308
+ end
309
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::MetaLearning::Helpers::Strategy do
4
+ subject(:strategy) { described_class.new(name: 'flash_cards', strategy_type: :repetition) }
5
+
6
+ describe '#initialize' do
7
+ it 'sets a uuid id' do
8
+ expect(strategy.id).to match(/\A[0-9a-f-]{36}\z/)
9
+ end
10
+
11
+ it 'sets name and strategy_type' do
12
+ expect(strategy.name).to eq('flash_cards')
13
+ expect(strategy.strategy_type).to eq(:repetition)
14
+ end
15
+
16
+ it 'starts with zero usage_count and success_count' do
17
+ expect(strategy.usage_count).to eq(0)
18
+ expect(strategy.success_count).to eq(0)
19
+ end
20
+ end
21
+
22
+ describe '#use!' do
23
+ it 'increments usage_count' do
24
+ strategy.use!(success: true)
25
+ expect(strategy.usage_count).to eq(1)
26
+ end
27
+
28
+ it 'increments success_count on success' do
29
+ strategy.use!(success: true)
30
+ expect(strategy.success_count).to eq(1)
31
+ end
32
+
33
+ it 'does not increment success_count on failure' do
34
+ strategy.use!(success: false)
35
+ expect(strategy.success_count).to eq(0)
36
+ end
37
+
38
+ it 'tracks domain names uniquely' do
39
+ strategy.use!(success: true, domain_name: 'ruby')
40
+ strategy.use!(success: true, domain_name: 'ruby')
41
+ strategy.use!(success: true, domain_name: 'python')
42
+ expect(strategy.domains_used.uniq.size).to eq(2)
43
+ end
44
+ end
45
+
46
+ describe '#success_rate' do
47
+ it 'returns 0.0 with no uses' do
48
+ expect(strategy.success_rate).to eq(0.0)
49
+ end
50
+
51
+ it 'returns 1.0 with all successes' do
52
+ 3.times { strategy.use!(success: true) }
53
+ expect(strategy.success_rate).to eq(1.0)
54
+ end
55
+
56
+ it 'returns 0.5 with equal success and failure' do
57
+ strategy.use!(success: true)
58
+ strategy.use!(success: false)
59
+ expect(strategy.success_rate).to eq(0.5)
60
+ end
61
+ end
62
+
63
+ describe '#versatility' do
64
+ it 'returns 0 for unused strategy' do
65
+ expect(strategy.versatility).to eq(0)
66
+ end
67
+
68
+ it 'returns count of unique domains used' do
69
+ strategy.use!(success: true, domain_name: 'ruby')
70
+ strategy.use!(success: true, domain_name: 'python')
71
+ expect(strategy.versatility).to eq(2)
72
+ end
73
+ end
74
+
75
+ describe '#to_h' do
76
+ it 'includes all expected keys' do
77
+ h = strategy.to_h
78
+ expect(h).to include(:id, :name, :strategy_type, :usage_count, :success_count,
79
+ :success_rate, :versatility, :domains_used, :created_at)
80
+ end
81
+ end
82
+ end