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.
- checksums.yaml +7 -0
- data/Gemfile +11 -0
- data/lex-meta-learning.gemspec +30 -0
- data/lib/legion/extensions/meta_learning/client.rb +23 -0
- data/lib/legion/extensions/meta_learning/helpers/constants.rb +42 -0
- data/lib/legion/extensions/meta_learning/helpers/learning_domain.rb +81 -0
- data/lib/legion/extensions/meta_learning/helpers/meta_learning_engine.rb +198 -0
- data/lib/legion/extensions/meta_learning/helpers/strategy.rb +58 -0
- data/lib/legion/extensions/meta_learning/runners/meta_learning.rb +114 -0
- data/lib/legion/extensions/meta_learning/version.rb +9 -0
- data/lib/legion/extensions/meta_learning.rb +17 -0
- data/spec/legion/extensions/meta_learning/client_spec.rb +27 -0
- data/spec/legion/extensions/meta_learning/helpers/constants_spec.rb +43 -0
- data/spec/legion/extensions/meta_learning/helpers/learning_domain_spec.rb +146 -0
- data/spec/legion/extensions/meta_learning/helpers/meta_learning_engine_spec.rb +309 -0
- data/spec/legion/extensions/meta_learning/helpers/strategy_spec.rb +82 -0
- data/spec/legion/extensions/meta_learning/runners/meta_learning_spec.rb +185 -0
- data/spec/spec_helper.rb +20 -0
- metadata +78 -0
|
@@ -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
|