lex-mind-growth 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.
Files changed (33) hide show
  1. checksums.yaml +7 -0
  2. data/Gemfile +19 -0
  3. data/LICENSE +21 -0
  4. data/lex-mind-growth.gemspec +29 -0
  5. data/lib/legion/extensions/mind_growth/actors/growth_cycle.rb +55 -0
  6. data/lib/legion/extensions/mind_growth/client.rb +36 -0
  7. data/lib/legion/extensions/mind_growth/helpers/build_pipeline.rb +63 -0
  8. data/lib/legion/extensions/mind_growth/helpers/cognitive_models.rb +60 -0
  9. data/lib/legion/extensions/mind_growth/helpers/concept_proposal.rb +69 -0
  10. data/lib/legion/extensions/mind_growth/helpers/constants.rb +55 -0
  11. data/lib/legion/extensions/mind_growth/helpers/fitness_evaluator.rb +58 -0
  12. data/lib/legion/extensions/mind_growth/helpers/proposal_store.rb +71 -0
  13. data/lib/legion/extensions/mind_growth/runners/analyzer.rb +52 -0
  14. data/lib/legion/extensions/mind_growth/runners/builder.rb +254 -0
  15. data/lib/legion/extensions/mind_growth/runners/orchestrator.rb +178 -0
  16. data/lib/legion/extensions/mind_growth/runners/proposer.rb +269 -0
  17. data/lib/legion/extensions/mind_growth/runners/validator.rb +57 -0
  18. data/lib/legion/extensions/mind_growth/version.rb +9 -0
  19. data/lib/legion/extensions/mind_growth.rb +25 -0
  20. data/spec/legion/extensions/mind_growth/actors/growth_cycle_spec.rb +81 -0
  21. data/spec/legion/extensions/mind_growth/client_spec.rb +111 -0
  22. data/spec/legion/extensions/mind_growth/helpers/build_pipeline_spec.rb +190 -0
  23. data/spec/legion/extensions/mind_growth/helpers/cognitive_models_spec.rb +106 -0
  24. data/spec/legion/extensions/mind_growth/helpers/concept_proposal_spec.rb +209 -0
  25. data/spec/legion/extensions/mind_growth/helpers/fitness_evaluator_spec.rb +123 -0
  26. data/spec/legion/extensions/mind_growth/helpers/proposal_store_spec.rb +203 -0
  27. data/spec/legion/extensions/mind_growth/runners/analyzer_spec.rb +108 -0
  28. data/spec/legion/extensions/mind_growth/runners/builder_spec.rb +347 -0
  29. data/spec/legion/extensions/mind_growth/runners/orchestrator_spec.rb +185 -0
  30. data/spec/legion/extensions/mind_growth/runners/proposer_spec.rb +493 -0
  31. data/spec/legion/extensions/mind_growth/runners/validator_spec.rb +120 -0
  32. data/spec/spec_helper.rb +22 -0
  33. metadata +91 -0
@@ -0,0 +1,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::MindGrowth::Helpers::BuildPipeline do
4
+ let(:proposal) do
5
+ Legion::Extensions::MindGrowth::Helpers::ConceptProposal.new(
6
+ name: 'lex-pipeline-test',
7
+ module_name: 'PipelineTest',
8
+ category: :cognition,
9
+ description: 'Test proposal for build pipeline'
10
+ )
11
+ end
12
+
13
+ subject(:pipeline) { described_class.new(proposal) }
14
+
15
+ describe '#initialize' do
16
+ it 'starts at scaffold stage' do
17
+ expect(pipeline.stage).to eq(:scaffold)
18
+ end
19
+
20
+ it 'has empty errors' do
21
+ expect(pipeline.errors).to be_empty
22
+ end
23
+
24
+ it 'records started_at' do
25
+ expect(pipeline.started_at).to be_a(Time)
26
+ end
27
+
28
+ it 'leaves completed_at nil' do
29
+ expect(pipeline.completed_at).to be_nil
30
+ end
31
+ end
32
+
33
+ describe '#advance!' do
34
+ context 'with successful result' do
35
+ it 'advances to next stage' do
36
+ pipeline.advance!({ success: true })
37
+ expect(pipeline.stage).to eq(:implement)
38
+ end
39
+
40
+ it 'progresses through all stages' do
41
+ described_class::STAGES[0...-2].each do
42
+ pipeline.advance!({ success: true })
43
+ end
44
+ expect(pipeline.stage).to eq(:complete)
45
+ end
46
+
47
+ it 'sets completed_at on :complete' do
48
+ described_class::STAGES[0...-2].each do
49
+ pipeline.advance!({ success: true })
50
+ end
51
+ expect(pipeline.completed_at).to be_a(Time)
52
+ end
53
+ end
54
+
55
+ context 'with failed result' do
56
+ it 'records the error' do
57
+ pipeline.advance!({ success: false, error: 'something went wrong' })
58
+ expect(pipeline.errors.size).to eq(1)
59
+ expect(pipeline.errors.first[:error]).to eq('something went wrong')
60
+ end
61
+
62
+ it 'does not advance stage on failure' do
63
+ pipeline.advance!({ success: false, error: 'err' })
64
+ expect(pipeline.stage).to eq(:scaffold)
65
+ end
66
+
67
+ it 'transitions to :failed after MAX_FIX_ATTEMPTS errors' do
68
+ Legion::Extensions::MindGrowth::Helpers::Constants::MAX_FIX_ATTEMPTS.times do
69
+ pipeline.advance!({ success: false, error: 'repeated error' })
70
+ end
71
+ expect(pipeline.stage).to eq(:failed)
72
+ end
73
+
74
+ it 'records stage in each error entry' do
75
+ pipeline.advance!({ success: false, error: 'err' })
76
+ expect(pipeline.errors.first[:stage]).to eq(:scaffold)
77
+ end
78
+
79
+ it 'records timestamp in each error entry' do
80
+ pipeline.advance!({ success: false, error: 'err' })
81
+ expect(pipeline.errors.first[:at]).to be_a(Time)
82
+ end
83
+ end
84
+
85
+ context 'when already complete' do
86
+ before do
87
+ described_class::STAGES[0...-2].each { pipeline.advance!({ success: true }) }
88
+ end
89
+
90
+ it 'ignores further advance! calls' do
91
+ pipeline.advance!({ success: true })
92
+ expect(pipeline.stage).to eq(:complete)
93
+ end
94
+
95
+ it 'does not add errors after completion' do
96
+ pipeline.advance!({ success: false, error: 'too late' })
97
+ expect(pipeline.errors).to be_empty
98
+ end
99
+ end
100
+
101
+ context 'when already failed' do
102
+ before do
103
+ Legion::Extensions::MindGrowth::Helpers::Constants::MAX_FIX_ATTEMPTS.times do
104
+ pipeline.advance!({ success: false, error: 'err' })
105
+ end
106
+ end
107
+
108
+ it 'ignores further advance! calls with success' do
109
+ pipeline.advance!({ success: true })
110
+ expect(pipeline.stage).to eq(:failed)
111
+ end
112
+
113
+ it 'does not accumulate more errors' do
114
+ count_before = pipeline.errors.size
115
+ pipeline.advance!({ success: false, error: 'extra' })
116
+ expect(pipeline.errors.size).to eq(count_before)
117
+ end
118
+ end
119
+ end
120
+
121
+ describe '#complete?' do
122
+ it 'returns false initially' do
123
+ expect(pipeline.complete?).to be false
124
+ end
125
+
126
+ it 'returns true after all stages complete' do
127
+ described_class::STAGES[0...-2].each do
128
+ pipeline.advance!({ success: true })
129
+ end
130
+ expect(pipeline.complete?).to be true
131
+ end
132
+ end
133
+
134
+ describe '#failed?' do
135
+ it 'returns false initially' do
136
+ expect(pipeline.failed?).to be false
137
+ end
138
+
139
+ it 'returns true after MAX_FIX_ATTEMPTS failures' do
140
+ Legion::Extensions::MindGrowth::Helpers::Constants::MAX_FIX_ATTEMPTS.times do
141
+ pipeline.advance!({ success: false, error: 'err' })
142
+ end
143
+ expect(pipeline.failed?).to be true
144
+ end
145
+ end
146
+
147
+ describe '#duration_ms' do
148
+ it 'returns a non-negative integer' do
149
+ expect(pipeline.duration_ms).to be >= 0
150
+ end
151
+ end
152
+
153
+ describe '#to_h' do
154
+ it 'includes proposal_id' do
155
+ expect(pipeline.to_h[:proposal_id]).to eq(proposal.id)
156
+ end
157
+
158
+ it 'includes current stage' do
159
+ expect(pipeline.to_h[:stage]).to eq(:scaffold)
160
+ end
161
+
162
+ it 'includes errors array' do
163
+ expect(pipeline.to_h[:errors]).to eq([])
164
+ end
165
+
166
+ it 'includes duration_ms' do
167
+ expect(pipeline.to_h[:duration_ms]).to be >= 0
168
+ end
169
+
170
+ it 'includes artifacts hash' do
171
+ expect(pipeline.to_h[:artifacts]).to eq({})
172
+ end
173
+
174
+ it 'records artifacts for completed stages' do
175
+ pipeline.advance!({ success: true, path: '/tmp/lex-test' })
176
+ expect(pipeline.to_h[:artifacts][:scaffold]).to include(path: '/tmp/lex-test')
177
+ end
178
+ end
179
+
180
+ describe '#artifacts' do
181
+ it 'is empty initially' do
182
+ expect(pipeline.artifacts).to eq({})
183
+ end
184
+
185
+ it 'stores artifact for each successful stage' do
186
+ pipeline.advance!({ success: true, files: 12 })
187
+ expect(pipeline.artifacts[:scaffold][:files]).to eq(12)
188
+ end
189
+ end
190
+ end
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::MindGrowth::Helpers::CognitiveModels do
4
+ subject(:models) { described_class }
5
+
6
+ describe '.gap_analysis' do
7
+ context 'with no existing extensions' do
8
+ it 'returns analysis for all cognitive models' do
9
+ result = models.gap_analysis([])
10
+ expect(result.size).to eq(5)
11
+ end
12
+
13
+ it 'reports zero coverage for all models' do
14
+ result = models.gap_analysis([])
15
+ result.each do |entry|
16
+ expect(entry[:coverage]).to eq(0.0)
17
+ end
18
+ end
19
+
20
+ it 'lists all required components as missing' do
21
+ result = models.gap_analysis([])
22
+ entry = result.find { |r| r[:model] == :global_workspace }
23
+ expect(entry[:missing]).to eq(%i[attention global_workspace broadcasting working_memory consciousness])
24
+ end
25
+ end
26
+
27
+ context 'with partial extensions' do
28
+ it 'calculates partial coverage' do
29
+ result = models.gap_analysis(%i[attention working_memory])
30
+ gw = result.find { |r| r[:model] == :global_workspace }
31
+ # has attention + working_memory out of 5 required = 0.40
32
+ expect(gw[:coverage]).to eq(0.4)
33
+ end
34
+
35
+ it 'removes matched components from missing list' do
36
+ result = models.gap_analysis(%i[attention])
37
+ gw = result.find { |r| r[:model] == :global_workspace }
38
+ expect(gw[:missing]).not_to include(:attention)
39
+ end
40
+ end
41
+
42
+ context 'with full coverage' do
43
+ it 'reports coverage of 1.0 when all required present' do
44
+ result = models.gap_analysis(%i[attention global_workspace broadcasting working_memory consciousness])
45
+ gw = result.find { |r| r[:model] == :global_workspace }
46
+ expect(gw[:coverage]).to eq(1.0)
47
+ expect(gw[:missing]).to be_empty
48
+ end
49
+ end
50
+
51
+ it 'includes model name in each entry' do
52
+ result = models.gap_analysis([])
53
+ result.each do |entry|
54
+ expect(entry[:name]).to be_a(String)
55
+ expect(entry[:name]).not_to be_empty
56
+ end
57
+ end
58
+
59
+ it 'includes total_required in each entry' do
60
+ result = models.gap_analysis([])
61
+ gw = result.find { |r| r[:model] == :global_workspace }
62
+ expect(gw[:total_required]).to eq(5)
63
+ end
64
+ end
65
+
66
+ describe '.recommend_from_gaps' do
67
+ it 'returns symbols most commonly missing across models' do
68
+ gaps = models.gap_analysis([])
69
+ recommendations = models.recommend_from_gaps(gaps)
70
+ expect(recommendations).to be_an(Array)
71
+ expect(recommendations).not_to be_empty
72
+ expect(recommendations.first).to be_a(Symbol)
73
+ end
74
+
75
+ it 'returns components missing in multiple models first' do
76
+ # attention appears in global_workspace and working_memory
77
+ gaps = models.gap_analysis([])
78
+ recommendations = models.recommend_from_gaps(gaps)
79
+ expect(recommendations).to include(:attention)
80
+ expect(recommendations).to include(:working_memory)
81
+ end
82
+
83
+ it 'returns empty array when no gaps' do
84
+ all_required = described_class::MODELS.values.flat_map { |m| m[:required] }.uniq
85
+ gaps = models.gap_analysis(all_required)
86
+ recommendations = models.recommend_from_gaps(gaps)
87
+ expect(recommendations).to be_empty
88
+ end
89
+ end
90
+
91
+ describe 'MODELS constant' do
92
+ it 'contains all five reference models' do
93
+ expect(described_class::MODELS.keys).to contain_exactly(
94
+ :global_workspace, :free_energy, :dual_process, :somatic_marker, :working_memory
95
+ )
96
+ end
97
+
98
+ it 'each model has required, name, and description' do
99
+ described_class::MODELS.each_value do |model|
100
+ expect(model[:required]).to be_an(Array)
101
+ expect(model[:name]).to be_a(String)
102
+ expect(model[:description]).to be_a(String)
103
+ end
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,209 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::MindGrowth::Helpers::ConceptProposal do
4
+ let(:valid_params) do
5
+ {
6
+ name: 'lex-attention',
7
+ module_name: 'Attention',
8
+ category: :cognition,
9
+ description: 'Attention gating for cognitive load management'
10
+ }
11
+ end
12
+
13
+ subject(:proposal) { described_class.new(**valid_params) }
14
+
15
+ describe '#initialize' do
16
+ it 'assigns a UUID id' do
17
+ expect(proposal.id).to match(/\A[0-9a-f-]{36}\z/)
18
+ end
19
+
20
+ it 'sets name' do
21
+ expect(proposal.name).to eq('lex-attention')
22
+ end
23
+
24
+ it 'sets module_name' do
25
+ expect(proposal.module_name).to eq('Attention')
26
+ end
27
+
28
+ it 'converts category to symbol' do
29
+ expect(proposal.category).to eq(:cognition)
30
+ end
31
+
32
+ it 'sets description' do
33
+ expect(proposal.description).to eq('Attention gating for cognitive load management')
34
+ end
35
+
36
+ it 'defaults metaphor to nil' do
37
+ expect(proposal.metaphor).to be_nil
38
+ end
39
+
40
+ it 'defaults helpers to empty array' do
41
+ expect(proposal.helpers).to eq([])
42
+ end
43
+
44
+ it 'defaults runner_methods to empty array' do
45
+ expect(proposal.runner_methods).to eq([])
46
+ end
47
+
48
+ it 'defaults rationale to nil' do
49
+ expect(proposal.rationale).to be_nil
50
+ end
51
+
52
+ it 'initializes scores to empty hash' do
53
+ expect(proposal.scores).to eq({})
54
+ end
55
+
56
+ it 'sets initial status to :proposed' do
57
+ expect(proposal.status).to eq(:proposed)
58
+ end
59
+
60
+ it 'defaults origin to :proposer' do
61
+ expect(proposal.origin).to eq(:proposer)
62
+ end
63
+
64
+ it 'sets created_at to a Time' do
65
+ expect(proposal.created_at).to be_a(Time)
66
+ end
67
+
68
+ it 'leaves evaluated_at nil' do
69
+ expect(proposal.evaluated_at).to be_nil
70
+ end
71
+
72
+ it 'leaves built_at nil' do
73
+ expect(proposal.built_at).to be_nil
74
+ end
75
+
76
+ it 'accepts optional metaphor' do
77
+ p = described_class.new(**valid_params, metaphor: 'spotlight')
78
+ expect(p.metaphor).to eq('spotlight')
79
+ end
80
+
81
+ it 'accepts optional rationale' do
82
+ p = described_class.new(**valid_params, rationale: 'fills gap in model')
83
+ expect(p.rationale).to eq('fills gap in model')
84
+ end
85
+
86
+ it 'accepts custom origin' do
87
+ p = described_class.new(**valid_params, origin: :manual)
88
+ expect(p.origin).to eq(:manual)
89
+ end
90
+ end
91
+
92
+ describe '#evaluate!' do
93
+ let(:passing_scores) do
94
+ Legion::Extensions::MindGrowth::Helpers::Constants::EVALUATION_DIMENSIONS.to_h { |d| [d, 0.75] }
95
+ end
96
+
97
+ let(:failing_scores) do
98
+ Legion::Extensions::MindGrowth::Helpers::Constants::EVALUATION_DIMENSIONS.to_h { |d| [d, 0.5] }
99
+ end
100
+
101
+ it 'sets scores' do
102
+ proposal.evaluate!(passing_scores)
103
+ expect(proposal.scores).to eq(passing_scores)
104
+ end
105
+
106
+ it 'sets evaluated_at' do
107
+ proposal.evaluate!(passing_scores)
108
+ expect(proposal.evaluated_at).to be_a(Time)
109
+ end
110
+
111
+ it 'approves when all scores >= MIN_DIMENSION_SCORE' do
112
+ proposal.evaluate!(passing_scores)
113
+ expect(proposal.status).to eq(:approved)
114
+ end
115
+
116
+ it 'rejects when any score < MIN_DIMENSION_SCORE' do
117
+ proposal.evaluate!(failing_scores)
118
+ expect(proposal.status).to eq(:rejected)
119
+ end
120
+ end
121
+
122
+ describe '#passing_evaluation?' do
123
+ it 'returns false with empty scores' do
124
+ expect(proposal.passing_evaluation?).to be false
125
+ end
126
+
127
+ it 'returns true when all dimensions meet threshold' do
128
+ scores = Legion::Extensions::MindGrowth::Helpers::Constants::EVALUATION_DIMENSIONS.to_h { |d| [d, 0.65] }
129
+ proposal.evaluate!(scores)
130
+ expect(proposal.passing_evaluation?).to be true
131
+ end
132
+
133
+ it 'returns false when any dimension is below threshold' do
134
+ scores = Legion::Extensions::MindGrowth::Helpers::Constants::EVALUATION_DIMENSIONS.to_h { |d| [d, 0.65] }
135
+ scores[scores.keys.first] = 0.4
136
+ proposal.evaluate!(scores)
137
+ expect(proposal.passing_evaluation?).to be false
138
+ end
139
+ end
140
+
141
+ describe '#auto_approvable?' do
142
+ it 'returns false with empty scores' do
143
+ expect(proposal.auto_approvable?).to be false
144
+ end
145
+
146
+ it 'returns true when all dimensions meet AUTO_APPROVE_THRESHOLD' do
147
+ scores = Legion::Extensions::MindGrowth::Helpers::Constants::EVALUATION_DIMENSIONS.to_h { |d| [d, 0.95] }
148
+ proposal.evaluate!(scores)
149
+ expect(proposal.auto_approvable?).to be true
150
+ end
151
+
152
+ it 'returns false when any dimension is below AUTO_APPROVE_THRESHOLD' do
153
+ scores = Legion::Extensions::MindGrowth::Helpers::Constants::EVALUATION_DIMENSIONS.to_h { |d| [d, 0.95] }
154
+ scores[scores.keys.first] = 0.8
155
+ proposal.evaluate!(scores)
156
+ expect(proposal.auto_approvable?).to be false
157
+ end
158
+ end
159
+
160
+ describe '#transition!' do
161
+ it 'updates status' do
162
+ proposal.transition!(:building)
163
+ expect(proposal.status).to eq(:building)
164
+ end
165
+
166
+ it 'sets built_at when transitioning to :passing' do
167
+ proposal.transition!(:passing)
168
+ expect(proposal.built_at).to be_a(Time)
169
+ end
170
+
171
+ it 'does not set built_at for other statuses' do
172
+ proposal.transition!(:building)
173
+ expect(proposal.built_at).to be_nil
174
+ end
175
+
176
+ it 'accepts string status and converts to symbol' do
177
+ proposal.transition!('building')
178
+ expect(proposal.status).to eq(:building)
179
+ end
180
+
181
+ it 'raises ArgumentError for invalid status' do
182
+ expect { proposal.transition!(:banana) }.to raise_error(ArgumentError, /invalid status.*banana/)
183
+ end
184
+
185
+ it 'accepts all valid PROPOSAL_STATUSES' do
186
+ Legion::Extensions::MindGrowth::Helpers::Constants::PROPOSAL_STATUSES.each do |status|
187
+ p = described_class.new(**valid_params)
188
+ expect { p.transition!(status) }.not_to raise_error
189
+ end
190
+ end
191
+ end
192
+
193
+ describe '#to_h' do
194
+ it 'returns a hash with all FIELDS' do
195
+ h = proposal.to_h
196
+ described_class::FIELDS.each do |f|
197
+ expect(h).to have_key(f)
198
+ end
199
+ end
200
+
201
+ it 'includes the id' do
202
+ expect(proposal.to_h[:id]).to eq(proposal.id)
203
+ end
204
+
205
+ it 'includes category as symbol' do
206
+ expect(proposal.to_h[:category]).to eq(:cognition)
207
+ end
208
+ end
209
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::MindGrowth::Helpers::FitnessEvaluator do
4
+ subject(:evaluator) { described_class }
5
+
6
+ let(:healthy_extension) do
7
+ {
8
+ invocation_count: 500,
9
+ impact_score: 0.8,
10
+ health_score: 0.95,
11
+ error_rate: 0.02,
12
+ avg_latency_ms: 100
13
+ }
14
+ end
15
+
16
+ let(:poor_extension) do
17
+ {
18
+ invocation_count: 0,
19
+ impact_score: 0.1,
20
+ health_score: 0.3,
21
+ error_rate: 0.8,
22
+ avg_latency_ms: 4000
23
+ }
24
+ end
25
+
26
+ let(:default_extension) { {} }
27
+
28
+ describe '.fitness' do
29
+ it 'returns a float between 0 and 1' do
30
+ score = evaluator.fitness(healthy_extension)
31
+ expect(score).to be_between(0.0, 1.0)
32
+ end
33
+
34
+ it 'returns higher score for healthy extension' do
35
+ healthy_score = evaluator.fitness(healthy_extension)
36
+ poor_score = evaluator.fitness(poor_extension)
37
+ expect(healthy_score).to be > poor_score
38
+ end
39
+
40
+ it 'returns a score for default extension with all defaults' do
41
+ score = evaluator.fitness(default_extension)
42
+ expect(score).to be_between(0.0, 1.0)
43
+ end
44
+
45
+ it 'returns a rounded value (3 decimal places)' do
46
+ score = evaluator.fitness(healthy_extension)
47
+ expect(score.to_s.split('.').last.length).to be <= 3
48
+ end
49
+
50
+ it 'clamps score to 0.0 minimum' do
51
+ very_bad = { invocation_count: 0, impact_score: 0.0, health_score: 0.0, error_rate: 1.0, avg_latency_ms: 5000 }
52
+ expect(evaluator.fitness(very_bad)).to be >= 0.0
53
+ end
54
+
55
+ it 'clamps score to 1.0 maximum' do
56
+ perfect = { invocation_count: 10_000, impact_score: 1.0, health_score: 1.0, error_rate: 0.0, avg_latency_ms: 0 }
57
+ expect(evaluator.fitness(perfect)).to be <= 1.0
58
+ end
59
+ end
60
+
61
+ describe '.rank' do
62
+ let(:extensions) { [poor_extension, healthy_extension] }
63
+
64
+ it 'returns extensions sorted by fitness descending' do
65
+ ranked = evaluator.rank(extensions)
66
+ expect(ranked.first[:fitness]).to be > ranked.last[:fitness]
67
+ end
68
+
69
+ it 'adds fitness key to each extension' do
70
+ ranked = evaluator.rank(extensions)
71
+ ranked.each do |e|
72
+ expect(e).to have_key(:fitness)
73
+ end
74
+ end
75
+
76
+ it 'does not modify original extensions' do
77
+ evaluator.rank(extensions)
78
+ expect(healthy_extension).not_to have_key(:fitness)
79
+ end
80
+ end
81
+
82
+ describe '.prune_candidates' do
83
+ it 'returns extensions with fitness below PRUNE_THRESHOLD' do
84
+ result = evaluator.prune_candidates([poor_extension, healthy_extension])
85
+ expect(result).to include(poor_extension)
86
+ expect(result).not_to include(healthy_extension)
87
+ end
88
+
89
+ it 'returns empty array when none qualify' do
90
+ expect(evaluator.prune_candidates([healthy_extension])).to be_empty
91
+ end
92
+ end
93
+
94
+ describe '.improvement_candidates' do
95
+ let(:mediocre_extension) do
96
+ {
97
+ invocation_count: 10,
98
+ impact_score: 0.35,
99
+ health_score: 0.6,
100
+ error_rate: 0.15,
101
+ avg_latency_ms: 500
102
+ }
103
+ end
104
+
105
+ it 'returns extensions between PRUNE_THRESHOLD and IMPROVEMENT_THRESHOLD' do
106
+ result = evaluator.improvement_candidates([mediocre_extension, poor_extension, healthy_extension])
107
+ # mediocre should fall in the improvement range; poor below prune; healthy above improvement
108
+ fitness_values = result.map { |e| evaluator.fitness(e) }
109
+ fitness_values.each do |f|
110
+ expect(f).to be >= Legion::Extensions::MindGrowth::Helpers::Constants::PRUNE_THRESHOLD
111
+ expect(f).to be < Legion::Extensions::MindGrowth::Helpers::Constants::IMPROVEMENT_THRESHOLD
112
+ end
113
+ end
114
+ end
115
+
116
+ describe 'log-scale invocation normalization' do
117
+ it 'maps zero invocations to 0.0' do
118
+ score_zero = evaluator.fitness({ invocation_count: 0, impact_score: 0.5, health_score: 1.0, error_rate: 0.0, avg_latency_ms: 0 })
119
+ score_some = evaluator.fitness({ invocation_count: 100, impact_score: 0.5, health_score: 1.0, error_rate: 0.0, avg_latency_ms: 0 })
120
+ expect(score_some).to be > score_zero
121
+ end
122
+ end
123
+ end