lex-mind-growth 0.1.8 → 0.1.9

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 87d35372e2ef409b603807723d816df60439d40bc8f4289d48799d31b731d232
4
- data.tar.gz: fd49c0c8bb06f49374cbbb3afb497f45ee58193e71ff66b1ef1c622b94bbeaa6
3
+ metadata.gz: 647d99aa35555ba0ff36f584ad78d0c2104a9c09e83d72fbee7b897d097c7380
4
+ data.tar.gz: b7803d7ea950fb03154b695b5631f8a2e523adfad0df4963f8a22bf98e952f67
5
5
  SHA512:
6
- metadata.gz: d3c07088c0173a6e5669af58bf3fbd315f2ef9098fa9e3f3f65228815159e6a061860c05aba14b75a0f0dcd9f01aea50f5a89ec9455a5796484037c6e1571328
7
- data.tar.gz: 98b9b07557efbb5a3546a4018300ab984a0bfecc24ae93ab2534c35c90b991877d5564863460a75137525b262c996b7536ca14c6bbd20f77671225a8501ba402
6
+ metadata.gz: d2874a80b17163ad6b0d9f92f6ce221b22e4bbecf317c779315279f5a7c4426154f342892c7765cf0d7e29f55941e9561d28a1d2d92cee5d7298151d5d47fc4c
7
+ data.tar.gz: 01c677bf65ca0c841f5740a7d1f89a41d160e1bb8bcd39f43ad247b4676a11d2c55bb67d4eaad7ed32c73e8838e1e1064839d0e4e461897b5a8af4c8c36945ca
@@ -68,6 +68,22 @@ module Legion
68
68
  def generate_dream_proposals(**) = Runners::DreamIdeation.generate_dream_proposals(**)
69
69
  def dream_agenda_items(**) = Runners::DreamIdeation.dream_agenda_items(**)
70
70
  def enrich_from_dream_context(**) = Runners::DreamIdeation.enrich_from_dream_context(**)
71
+
72
+ # Evolver delegation
73
+ def select_for_improvement(**) = Runners::Evolver.select_for_improvement(**)
74
+ def propose_improvement(**) = Runners::Evolver.propose_improvement(**)
75
+ def replace_extension(**) = Runners::Evolver.replace_extension(**)
76
+ def merge_extensions(**) = Runners::Evolver.merge_extensions(**)
77
+ def evolution_summary(**) = Runners::Evolver.evolution_summary(**)
78
+
79
+ # Dashboard delegation
80
+ def extension_timeline(**) = Runners::Dashboard.extension_timeline(**)
81
+ def category_distribution(**) = Runners::Dashboard.category_distribution(**)
82
+ def build_metrics(**) = Runners::Dashboard.build_metrics(**)
83
+ def top_extensions(**) = Runners::Dashboard.top_extensions(**)
84
+ def bottom_extensions(**) = Runners::Dashboard.bottom_extensions(**)
85
+ def recent_proposals(**) = Runners::Dashboard.recent_proposals(**)
86
+ def full_dashboard(**) = Runners::Dashboard.full_dashboard(**)
71
87
  end
72
88
  end
73
89
  end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module MindGrowth
6
+ module Runners
7
+ module Dashboard
8
+ include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers) &&
9
+ Legion::Extensions::Helpers.const_defined?(:Lex)
10
+
11
+ module_function
12
+
13
+ def extension_timeline(extensions:, days: 30, **)
14
+ count = Array(extensions).size
15
+ today = Time.now.utc.strftime('%Y-%m-%d')
16
+ series = [{ date: today, count: count }]
17
+
18
+ { success: true, series: series, range_days: days }
19
+ end
20
+
21
+ def category_distribution(extensions:, **)
22
+ exts = Array(extensions)
23
+ dist = Helpers::Constants::CATEGORIES.to_h { |c| [c, 0] }
24
+
25
+ exts.each do |ext|
26
+ cat = (ext[:category] || :cognition).to_sym
27
+ dist[cat] = (dist[cat] || 0) + 1
28
+ end
29
+
30
+ { success: true, distribution: dist, total: exts.size }
31
+ end
32
+
33
+ def build_metrics(**)
34
+ stats = Runners::Proposer.proposal_stats[:stats]
35
+ by_status = stats[:by_status] || {}
36
+ total = stats[:total] || 0
37
+
38
+ approved = by_status[:approved].to_i
39
+ rejected = by_status[:rejected].to_i
40
+ built = (by_status[:passing].to_i + by_status[:wired].to_i + by_status[:active].to_i)
41
+ failed = by_status[:build_failed].to_i
42
+
43
+ attempted = built + failed
44
+ success_rate = attempted.positive? ? (built.to_f / attempted).round(3) : 0.0
45
+
46
+ evaluated = approved + rejected
47
+ approval_rate = evaluated.positive? ? (approved.to_f / evaluated).round(3) : 0.0
48
+
49
+ { success: true,
50
+ total_proposals: total,
51
+ approved: approved,
52
+ rejected: rejected,
53
+ built: built,
54
+ failed: failed,
55
+ success_rate: success_rate,
56
+ approval_rate: approval_rate }
57
+ end
58
+
59
+ def top_extensions(extensions:, limit: 10, **)
60
+ exts = Array(extensions)
61
+ ranked = Helpers::FitnessEvaluator.rank(exts)
62
+ top = ranked.first(limit).map do |e|
63
+ { name: e[:name] || e[:extension_name],
64
+ invocation_count: e[:invocation_count] || 0,
65
+ fitness: e[:fitness] }
66
+ end
67
+
68
+ { success: true, top: top, limit: limit }
69
+ end
70
+
71
+ def bottom_extensions(extensions:, limit: 10, **)
72
+ exts = Array(extensions)
73
+ ranked = Helpers::FitnessEvaluator.rank(exts)
74
+ bottom = ranked.last(limit).reverse.map do |e|
75
+ { name: e[:name] || e[:extension_name],
76
+ invocation_count: e[:invocation_count] || 0,
77
+ fitness: e[:fitness] }
78
+ end
79
+
80
+ { success: true, bottom: bottom, limit: limit }
81
+ end
82
+
83
+ def recent_proposals(limit: 10, **)
84
+ result = Runners::Proposer.list_proposals(limit: limit)
85
+ { success: true, proposals: result[:proposals], count: result[:count] }
86
+ end
87
+
88
+ def full_dashboard(extensions:, **)
89
+ exts = Array(extensions)
90
+
91
+ { success: true,
92
+ category_distribution: category_distribution(extensions: exts)[:distribution],
93
+ build_metrics: build_metrics,
94
+ top_extensions: top_extensions(extensions: exts)[:top],
95
+ bottom_extensions: bottom_extensions(extensions: exts)[:bottom],
96
+ recent_proposals: recent_proposals[:proposals],
97
+ health_summary: Runners::Monitor.health_summary(extensions: exts),
98
+ timestamp: Time.now.utc.iso8601 }
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module MindGrowth
6
+ module Runners
7
+ module Evolver
8
+ include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers) &&
9
+ Legion::Extensions::Helpers.const_defined?(:Lex)
10
+
11
+ extend self
12
+
13
+ BOTTOM_PERCENTILE = 0.05
14
+ SPECIATION_DRIFT_THRESHOLD = 0.5
15
+
16
+ def select_for_improvement(extensions:, count: 3, **)
17
+ exts = Array(extensions)
18
+ return { success: true, candidates: [], count: 0, total_evaluated: 0 } if exts.empty?
19
+
20
+ eligible = exts.reject { |e| %i[building testing].include?((e[:status] || :active).to_sym) }
21
+ ranked = Helpers::FitnessEvaluator.rank(eligible)
22
+ bottom_n = ranked.last(count)
23
+
24
+ { success: true, candidates: bottom_n, count: bottom_n.size, total_evaluated: eligible.size }
25
+ end
26
+
27
+ def propose_improvement(extension:, **)
28
+ name = extension[:name] || extension[:extension_name]
29
+ fitness = Helpers::FitnessEvaluator.fitness(extension)
30
+
31
+ weaknesses = identify_weaknesses(extension)
32
+ suggestions = generate_suggestions(weaknesses)
33
+
34
+ if defined?(Legion::LLM) && Legion::LLM.respond_to?(:started?) && Legion::LLM.started?
35
+ suggestions = llm_suggestions(name, fitness, weaknesses) || suggestions
36
+ end
37
+
38
+ { success: true, extension_name: name, fitness: fitness,
39
+ weaknesses: weaknesses, suggestions: suggestions }
40
+ end
41
+
42
+ def replace_extension(old_name:, new_proposal_id:, **)
43
+ status_store[old_name] = :pruned
44
+ replacement_map[old_name] = new_proposal_id
45
+
46
+ { success: true, replaced: old_name, replacement_proposal_id: new_proposal_id }
47
+ end
48
+
49
+ def merge_extensions(extension_a:, extension_b:, merged_name: nil, **)
50
+ name_a = extension_a[:name] || extension_a[:extension_name]
51
+ name_b = extension_b[:name] || extension_b[:extension_name]
52
+ cat_a = (extension_a[:category] || :cognition).to_sym
53
+ merged = merged_name || "lex-merged-#{name_a.to_s.sub(/\Alex-/, '')}-#{name_b.to_s.sub(/\Alex-/, '')}"
54
+ desc = "Merged extension combining capabilities of #{name_a} and #{name_b}"
55
+
56
+ proposal = Runners::Proposer.propose_concept(
57
+ name: merged,
58
+ category: cat_a,
59
+ description: desc,
60
+ enrich: false
61
+ )
62
+
63
+ { success: true, merged_proposal: proposal, sources: [name_a, name_b] }
64
+ end
65
+
66
+ def evolution_summary(extensions:, **)
67
+ exts = Array(extensions)
68
+
69
+ improvement_candidates = select_for_improvement(extensions: exts, count: 5)[:candidates]
70
+
71
+ prune_candidates = Helpers::FitnessEvaluator.prune_candidates(exts).map do |e|
72
+ e[:name] || e[:extension_name]
73
+ end
74
+
75
+ speciation_candidates = exts.filter_map do |e|
76
+ e[:name] || e[:extension_name] if (e[:drift_score] || 0.0) >= SPECIATION_DRIFT_THRESHOLD
77
+ end
78
+
79
+ fitnesses = exts.map { |e| Helpers::FitnessEvaluator.fitness(e) }
80
+
81
+ distribution = if fitnesses.empty?
82
+ { min: 0.0, max: 0.0, mean: 0.0, median: 0.0 }
83
+ else
84
+ sorted = fitnesses.sort
85
+ mid = sorted.size / 2
86
+ median = sorted.size.odd? ? sorted[mid] : ((sorted[mid - 1] + sorted[mid]) / 2.0).round(3)
87
+ { min: sorted.first.round(3),
88
+ max: sorted.last.round(3),
89
+ mean: (fitnesses.sum / fitnesses.size.to_f).round(3),
90
+ median: median }
91
+ end
92
+
93
+ { success: true,
94
+ improvement_candidates: improvement_candidates,
95
+ prune_candidates: prune_candidates,
96
+ speciation_candidates: speciation_candidates,
97
+ fitness_distribution: distribution }
98
+ end
99
+
100
+ SUGGESTION_MAP = {
101
+ low_invocations: 'improve wiring or broaden phase coverage',
102
+ high_error_rate: 'add error handling and input validation',
103
+ high_latency: 'optimize hot paths or add caching',
104
+ low_impact: 'enrich output or add downstream connections'
105
+ }.freeze
106
+
107
+ private
108
+
109
+ def identify_weaknesses(extension)
110
+ weaknesses = []
111
+ count = extension[:invocation_count] || 0
112
+ error = extension[:error_rate] || 0.0
113
+ lat = extension[:avg_latency_ms] || 0
114
+ imp = extension[:impact_score] || 0.5
115
+
116
+ weaknesses << :low_invocations if count < Helpers::Constants::DECAY_INVOCATION_THRESHOLD
117
+ weaknesses << :high_error_rate if error > 0.2
118
+ weaknesses << :high_latency if lat > 1000
119
+ weaknesses << :low_impact if imp < 0.3
120
+ weaknesses
121
+ end
122
+
123
+ def generate_suggestions(weaknesses)
124
+ suggestions = weaknesses.filter_map { |w| SUGGESTION_MAP[w] }
125
+ suggestions.empty? ? ['review overall design for incremental improvements'] : suggestions
126
+ end
127
+
128
+ def llm_suggestions(name, fitness, weaknesses)
129
+ response = Legion::LLM.chat(
130
+ caller: { extension: 'lex-mind-growth', operation: 'evolver', phase: 'suggest' }
131
+ ).ask(improvement_prompt(name, fitness, weaknesses))
132
+ parse_llm_suggestions(response.content)
133
+ rescue StandardError
134
+ nil
135
+ end
136
+
137
+ def improvement_prompt(name, fitness, weaknesses)
138
+ <<~PROMPT
139
+ The LegionIO cognitive extension "#{name}" has a fitness score of #{fitness.round(3)}.
140
+ Identified weaknesses: #{weaknesses.join(', ')}.
141
+
142
+ Provide 2-4 concrete improvement suggestions as a JSON array of strings.
143
+ Example: ["suggestion one", "suggestion two"]
144
+ Return ONLY the JSON array, no markdown fencing.
145
+ PROMPT
146
+ end
147
+
148
+ def parse_llm_suggestions(content)
149
+ cleaned = content.gsub(/```(?:json)?\s*\n?/, '').strip
150
+ data = ::JSON.parse(cleaned)
151
+ return nil unless data.is_a?(Array)
152
+
153
+ data.map(&:to_s).reject(&:empty?)
154
+ rescue ::JSON::ParserError
155
+ nil
156
+ end
157
+
158
+ def status_store
159
+ @status_store ||= {}
160
+ end
161
+
162
+ def replacement_map
163
+ @replacement_map ||= {}
164
+ end
165
+ end
166
+ end
167
+ end
168
+ end
169
+ end
@@ -3,7 +3,7 @@
3
3
  module Legion
4
4
  module Extensions
5
5
  module MindGrowth
6
- VERSION = '0.1.8'
6
+ VERSION = '0.1.9'
7
7
  end
8
8
  end
9
9
  end
@@ -24,6 +24,8 @@ require 'legion/extensions/mind_growth/helpers/composition_map'
24
24
  require 'legion/extensions/mind_growth/runners/monitor'
25
25
  require 'legion/extensions/mind_growth/runners/composer'
26
26
  require 'legion/extensions/mind_growth/runners/dream_ideation'
27
+ require 'legion/extensions/mind_growth/runners/evolver'
28
+ require 'legion/extensions/mind_growth/runners/dashboard'
27
29
  require 'legion/extensions/mind_growth/client'
28
30
 
29
31
  module Legion
@@ -0,0 +1,350 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::MindGrowth::Runners::Dashboard do
4
+ subject(:dashboard) { described_class }
5
+
6
+ before { Legion::Extensions::MindGrowth::Runners::Proposer.instance_variable_set(:@proposal_store, nil) }
7
+
8
+ let(:cognition_ext) do
9
+ { name: 'lex-cognition', category: :cognition, invocation_count: 500,
10
+ impact_score: 0.8, health_score: 0.9, error_rate: 0.0, avg_latency_ms: 100 }
11
+ end
12
+
13
+ let(:memory_ext) do
14
+ { name: 'lex-memory', category: :memory, invocation_count: 200,
15
+ impact_score: 0.6, health_score: 0.7, error_rate: 0.05, avg_latency_ms: 150 }
16
+ end
17
+
18
+ let(:low_ext) do
19
+ { name: 'lex-low', category: :perception, invocation_count: 1,
20
+ impact_score: 0.1, health_score: 0.1, error_rate: 0.8, avg_latency_ms: 3000 }
21
+ end
22
+
23
+ let(:all_exts) { [cognition_ext, memory_ext, low_ext] }
24
+
25
+ # ─── extension_timeline ────────────────────────────────────────────────────
26
+
27
+ describe '.extension_timeline' do
28
+ it 'returns success: true' do
29
+ result = dashboard.extension_timeline(extensions: all_exts)
30
+ expect(result[:success]).to be true
31
+ end
32
+
33
+ it 'returns a series array' do
34
+ result = dashboard.extension_timeline(extensions: all_exts)
35
+ expect(result[:series]).to be_an(Array)
36
+ end
37
+
38
+ it 'returns at least one series point' do
39
+ result = dashboard.extension_timeline(extensions: all_exts)
40
+ expect(result[:series].size).to be >= 1
41
+ end
42
+
43
+ it 'returns range_days matching requested days' do
44
+ result = dashboard.extension_timeline(extensions: all_exts, days: 7)
45
+ expect(result[:range_days]).to eq(7)
46
+ end
47
+
48
+ it 'series points include date and count keys' do
49
+ result = dashboard.extension_timeline(extensions: all_exts)
50
+ result[:series].each do |point|
51
+ expect(point).to have_key(:date)
52
+ expect(point).to have_key(:count)
53
+ end
54
+ end
55
+
56
+ it 'reflects current extension count in the series' do
57
+ result = dashboard.extension_timeline(extensions: all_exts)
58
+ expect(result[:series].last[:count]).to eq(3)
59
+ end
60
+
61
+ it 'returns count 0 for empty extensions' do
62
+ result = dashboard.extension_timeline(extensions: [])
63
+ expect(result[:series].last[:count]).to eq(0)
64
+ end
65
+ end
66
+
67
+ # ─── category_distribution ─────────────────────────────────────────────────
68
+
69
+ describe '.category_distribution' do
70
+ it 'returns success: true' do
71
+ result = dashboard.category_distribution(extensions: all_exts)
72
+ expect(result[:success]).to be true
73
+ end
74
+
75
+ it 'returns a distribution hash' do
76
+ result = dashboard.category_distribution(extensions: all_exts)
77
+ expect(result[:distribution]).to be_a(Hash)
78
+ end
79
+
80
+ it 'returns total equal to extension count' do
81
+ result = dashboard.category_distribution(extensions: all_exts)
82
+ expect(result[:total]).to eq(3)
83
+ end
84
+
85
+ it 'includes all CATEGORIES keys in distribution' do
86
+ result = dashboard.category_distribution(extensions: all_exts)
87
+ Legion::Extensions::MindGrowth::Helpers::Constants::CATEGORIES.each do |cat|
88
+ expect(result[:distribution]).to have_key(cat)
89
+ end
90
+ end
91
+
92
+ it 'counts extensions per category correctly' do
93
+ result = dashboard.category_distribution(extensions: all_exts)
94
+ expect(result[:distribution][:cognition]).to eq(1)
95
+ expect(result[:distribution][:memory]).to eq(1)
96
+ expect(result[:distribution][:perception]).to eq(1)
97
+ end
98
+
99
+ it 'returns zero counts for unpopulated categories' do
100
+ result = dashboard.category_distribution(extensions: [cognition_ext])
101
+ expect(result[:distribution][:memory]).to eq(0)
102
+ end
103
+
104
+ it 'defaults missing category to :cognition' do
105
+ ext = { name: 'lex-bare' }
106
+ result = dashboard.category_distribution(extensions: [ext])
107
+ expect(result[:distribution][:cognition]).to eq(1)
108
+ end
109
+
110
+ it 'returns total: 0 for empty extensions' do
111
+ result = dashboard.category_distribution(extensions: [])
112
+ expect(result[:total]).to eq(0)
113
+ end
114
+ end
115
+
116
+ # ─── build_metrics ─────────────────────────────────────────────────────────
117
+
118
+ describe '.build_metrics' do
119
+ it 'returns success: true' do
120
+ result = dashboard.build_metrics
121
+ expect(result[:success]).to be true
122
+ end
123
+
124
+ it 'returns total_proposals' do
125
+ result = dashboard.build_metrics
126
+ expect(result).to have_key(:total_proposals)
127
+ end
128
+
129
+ it 'returns approved count' do
130
+ result = dashboard.build_metrics
131
+ expect(result).to have_key(:approved)
132
+ end
133
+
134
+ it 'returns rejected count' do
135
+ result = dashboard.build_metrics
136
+ expect(result).to have_key(:rejected)
137
+ end
138
+
139
+ it 'returns built count' do
140
+ result = dashboard.build_metrics
141
+ expect(result).to have_key(:built)
142
+ end
143
+
144
+ it 'returns failed count' do
145
+ result = dashboard.build_metrics
146
+ expect(result).to have_key(:failed)
147
+ end
148
+
149
+ it 'returns success_rate as a numeric' do
150
+ result = dashboard.build_metrics
151
+ expect(result[:success_rate]).to be_a(Numeric)
152
+ end
153
+
154
+ it 'returns approval_rate as a numeric' do
155
+ result = dashboard.build_metrics
156
+ expect(result[:approval_rate]).to be_a(Numeric)
157
+ end
158
+
159
+ it 'returns success_rate 0.0 when no builds attempted' do
160
+ result = dashboard.build_metrics
161
+ expect(result[:success_rate]).to eq(0.0)
162
+ end
163
+
164
+ it 'returns approval_rate 0.0 when no proposals evaluated' do
165
+ result = dashboard.build_metrics
166
+ expect(result[:approval_rate]).to eq(0.0)
167
+ end
168
+ end
169
+
170
+ # ─── top_extensions ────────────────────────────────────────────────────────
171
+
172
+ describe '.top_extensions' do
173
+ it 'returns success: true' do
174
+ result = dashboard.top_extensions(extensions: all_exts)
175
+ expect(result[:success]).to be true
176
+ end
177
+
178
+ it 'returns a top array' do
179
+ result = dashboard.top_extensions(extensions: all_exts)
180
+ expect(result[:top]).to be_an(Array)
181
+ end
182
+
183
+ it 'returns limit in response' do
184
+ result = dashboard.top_extensions(extensions: all_exts, limit: 2)
185
+ expect(result[:limit]).to eq(2)
186
+ end
187
+
188
+ it 'respects the limit parameter' do
189
+ result = dashboard.top_extensions(extensions: all_exts, limit: 2)
190
+ expect(result[:top].size).to be <= 2
191
+ end
192
+
193
+ it 'returns highest-fitness extension first' do
194
+ result = dashboard.top_extensions(extensions: all_exts, limit: 1)
195
+ expect(result[:top].first[:name]).to eq('lex-cognition')
196
+ end
197
+
198
+ it 'each entry includes name, invocation_count, and fitness' do
199
+ result = dashboard.top_extensions(extensions: all_exts)
200
+ result[:top].each do |entry|
201
+ expect(entry).to have_key(:name)
202
+ expect(entry).to have_key(:invocation_count)
203
+ expect(entry).to have_key(:fitness)
204
+ end
205
+ end
206
+
207
+ it 'returns empty top array for empty extensions' do
208
+ result = dashboard.top_extensions(extensions: [])
209
+ expect(result[:top]).to be_empty
210
+ end
211
+ end
212
+
213
+ # ─── bottom_extensions ─────────────────────────────────────────────────────
214
+
215
+ describe '.bottom_extensions' do
216
+ it 'returns success: true' do
217
+ result = dashboard.bottom_extensions(extensions: all_exts)
218
+ expect(result[:success]).to be true
219
+ end
220
+
221
+ it 'returns a bottom array' do
222
+ result = dashboard.bottom_extensions(extensions: all_exts)
223
+ expect(result[:bottom]).to be_an(Array)
224
+ end
225
+
226
+ it 'returns limit in response' do
227
+ result = dashboard.bottom_extensions(extensions: all_exts, limit: 2)
228
+ expect(result[:limit]).to eq(2)
229
+ end
230
+
231
+ it 'respects the limit parameter' do
232
+ result = dashboard.bottom_extensions(extensions: all_exts, limit: 2)
233
+ expect(result[:bottom].size).to be <= 2
234
+ end
235
+
236
+ it 'returns lowest-fitness extension first' do
237
+ result = dashboard.bottom_extensions(extensions: all_exts, limit: 1)
238
+ expect(result[:bottom].first[:name]).to eq('lex-low')
239
+ end
240
+
241
+ it 'each entry includes name, invocation_count, and fitness' do
242
+ result = dashboard.bottom_extensions(extensions: all_exts)
243
+ result[:bottom].each do |entry|
244
+ expect(entry).to have_key(:name)
245
+ expect(entry).to have_key(:invocation_count)
246
+ expect(entry).to have_key(:fitness)
247
+ end
248
+ end
249
+
250
+ it 'returns empty bottom array for empty extensions' do
251
+ result = dashboard.bottom_extensions(extensions: [])
252
+ expect(result[:bottom]).to be_empty
253
+ end
254
+ end
255
+
256
+ # ─── recent_proposals ──────────────────────────────────────────────────────
257
+
258
+ describe '.recent_proposals' do
259
+ it 'returns success: true' do
260
+ result = dashboard.recent_proposals
261
+ expect(result[:success]).to be true
262
+ end
263
+
264
+ it 'returns a proposals array' do
265
+ result = dashboard.recent_proposals
266
+ expect(result[:proposals]).to be_an(Array)
267
+ end
268
+
269
+ it 'returns count matching proposals array size' do
270
+ result = dashboard.recent_proposals
271
+ expect(result[:count]).to eq(result[:proposals].size)
272
+ end
273
+
274
+ it 'delegates to Proposer.list_proposals' do
275
+ allow(Legion::Extensions::MindGrowth::Runners::Proposer)
276
+ .to receive(:list_proposals).with(limit: 5).and_call_original
277
+ dashboard.recent_proposals(limit: 5)
278
+ expect(Legion::Extensions::MindGrowth::Runners::Proposer)
279
+ .to have_received(:list_proposals).with(limit: 5)
280
+ end
281
+
282
+ it 'returns proposals when store has entries' do
283
+ Legion::Extensions::MindGrowth::Runners::Proposer.propose_concept(
284
+ name: 'lex-dash-test', category: :cognition, description: 'dashboard test', enrich: false
285
+ )
286
+ result = dashboard.recent_proposals(limit: 10)
287
+ expect(result[:count]).to be >= 1
288
+ end
289
+
290
+ it 'returns empty list when no proposals exist' do
291
+ result = dashboard.recent_proposals
292
+ expect(result[:proposals]).to be_an(Array)
293
+ end
294
+ end
295
+
296
+ # ─── full_dashboard ────────────────────────────────────────────────────────
297
+
298
+ describe '.full_dashboard' do
299
+ it 'returns success: true' do
300
+ result = dashboard.full_dashboard(extensions: all_exts)
301
+ expect(result[:success]).to be true
302
+ end
303
+
304
+ it 'includes category_distribution' do
305
+ result = dashboard.full_dashboard(extensions: all_exts)
306
+ expect(result[:category_distribution]).to be_a(Hash)
307
+ end
308
+
309
+ it 'includes build_metrics' do
310
+ result = dashboard.full_dashboard(extensions: all_exts)
311
+ expect(result[:build_metrics]).to be_a(Hash)
312
+ end
313
+
314
+ it 'includes top_extensions array' do
315
+ result = dashboard.full_dashboard(extensions: all_exts)
316
+ expect(result[:top_extensions]).to be_an(Array)
317
+ end
318
+
319
+ it 'includes bottom_extensions array' do
320
+ result = dashboard.full_dashboard(extensions: all_exts)
321
+ expect(result[:bottom_extensions]).to be_an(Array)
322
+ end
323
+
324
+ it 'includes recent_proposals array' do
325
+ result = dashboard.full_dashboard(extensions: all_exts)
326
+ expect(result[:recent_proposals]).to be_an(Array)
327
+ end
328
+
329
+ it 'includes health_summary hash' do
330
+ result = dashboard.full_dashboard(extensions: all_exts)
331
+ expect(result[:health_summary]).to be_a(Hash)
332
+ end
333
+
334
+ it 'includes a timestamp string' do
335
+ result = dashboard.full_dashboard(extensions: all_exts)
336
+ expect(result[:timestamp]).to be_a(String)
337
+ end
338
+
339
+ it 'health_summary reflects extension count' do
340
+ result = dashboard.full_dashboard(extensions: all_exts)
341
+ expect(result[:health_summary][:total]).to eq(3)
342
+ end
343
+
344
+ it 'returns empty arrays and zero counts for empty extensions' do
345
+ result = dashboard.full_dashboard(extensions: [])
346
+ expect(result[:top_extensions]).to be_empty
347
+ expect(result[:bottom_extensions]).to be_empty
348
+ end
349
+ end
350
+ end
@@ -0,0 +1,357 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::MindGrowth::Runners::Evolver do
4
+ subject(:evolver) { described_class }
5
+
6
+ before do
7
+ Legion::Extensions::MindGrowth::Runners::Proposer.instance_variable_set(:@proposal_store, nil)
8
+ evolver.instance_variable_set(:@status_store, nil)
9
+ evolver.instance_variable_set(:@replacement_map, nil)
10
+ end
11
+
12
+ let(:high_ext) do
13
+ { name: 'lex-high', invocation_count: 5000, impact_score: 0.9,
14
+ health_score: 1.0, error_rate: 0.0, avg_latency_ms: 0, status: :active }
15
+ end
16
+
17
+ let(:mid_ext) do
18
+ { name: 'lex-mid', invocation_count: 50, impact_score: 0.5,
19
+ health_score: 0.7, error_rate: 0.1, avg_latency_ms: 200, status: :active }
20
+ end
21
+
22
+ let(:low_ext) do
23
+ { name: 'lex-low', invocation_count: 1, impact_score: 0.1,
24
+ health_score: 0.1, error_rate: 0.5, avg_latency_ms: 2000, status: :active }
25
+ end
26
+
27
+ let(:building_ext) do
28
+ { name: 'lex-building', invocation_count: 0, impact_score: 0.0,
29
+ health_score: 0.0, error_rate: 1.0, avg_latency_ms: 5000, status: :building }
30
+ end
31
+
32
+ let(:testing_ext) do
33
+ { name: 'lex-testing', invocation_count: 0, impact_score: 0.0,
34
+ health_score: 0.0, error_rate: 1.0, avg_latency_ms: 5000, status: :testing }
35
+ end
36
+
37
+ # ─── select_for_improvement ────────────────────────────────────────────────
38
+
39
+ describe '.select_for_improvement' do
40
+ it 'returns success: true' do
41
+ result = evolver.select_for_improvement(extensions: [high_ext, mid_ext, low_ext])
42
+ expect(result[:success]).to be true
43
+ end
44
+
45
+ it 'returns a candidates array' do
46
+ result = evolver.select_for_improvement(extensions: [high_ext, mid_ext, low_ext])
47
+ expect(result[:candidates]).to be_an(Array)
48
+ end
49
+
50
+ it 'returns count matching candidates array size' do
51
+ result = evolver.select_for_improvement(extensions: [high_ext, mid_ext, low_ext])
52
+ expect(result[:count]).to eq(result[:candidates].size)
53
+ end
54
+
55
+ it 'returns total_evaluated count' do
56
+ result = evolver.select_for_improvement(extensions: [high_ext, mid_ext, low_ext])
57
+ expect(result[:total_evaluated]).to eq(3)
58
+ end
59
+
60
+ it 'selects the bottom N extensions by fitness' do
61
+ result = evolver.select_for_improvement(extensions: [high_ext, mid_ext, low_ext], count: 1)
62
+ expect(result[:candidates].first[:name]).to eq('lex-low')
63
+ end
64
+
65
+ it 'respects the count parameter' do
66
+ result = evolver.select_for_improvement(extensions: [high_ext, mid_ext, low_ext], count: 2)
67
+ expect(result[:count]).to eq(2)
68
+ end
69
+
70
+ it 'skips extensions with :building status' do
71
+ result = evolver.select_for_improvement(extensions: [high_ext, building_ext], count: 2)
72
+ names = result[:candidates].map { |c| c[:name] }
73
+ expect(names).not_to include('lex-building')
74
+ end
75
+
76
+ it 'skips extensions with :testing status' do
77
+ result = evolver.select_for_improvement(extensions: [high_ext, testing_ext], count: 2)
78
+ names = result[:candidates].map { |c| c[:name] }
79
+ expect(names).not_to include('lex-testing')
80
+ end
81
+
82
+ it 'does not count skipped extensions in total_evaluated' do
83
+ result = evolver.select_for_improvement(extensions: [high_ext, building_ext, low_ext], count: 2)
84
+ expect(result[:total_evaluated]).to eq(2)
85
+ end
86
+
87
+ it 'returns empty candidates for empty extensions list' do
88
+ result = evolver.select_for_improvement(extensions: [])
89
+ expect(result[:candidates]).to be_empty
90
+ expect(result[:count]).to eq(0)
91
+ end
92
+
93
+ it 'handles count greater than total extensions' do
94
+ result = evolver.select_for_improvement(extensions: [high_ext], count: 10)
95
+ expect(result[:count]).to eq(1)
96
+ end
97
+
98
+ it 'returns all extensions when count equals list size' do
99
+ result = evolver.select_for_improvement(extensions: [high_ext, low_ext], count: 2)
100
+ expect(result[:count]).to eq(2)
101
+ end
102
+ end
103
+
104
+ # ─── propose_improvement ───────────────────────────────────────────────────
105
+
106
+ describe '.propose_improvement' do
107
+ it 'returns success: true' do
108
+ result = evolver.propose_improvement(extension: low_ext)
109
+ expect(result[:success]).to be true
110
+ end
111
+
112
+ it 'returns the extension_name' do
113
+ result = evolver.propose_improvement(extension: low_ext)
114
+ expect(result[:extension_name]).to eq('lex-low')
115
+ end
116
+
117
+ it 'returns a numeric fitness' do
118
+ result = evolver.propose_improvement(extension: low_ext)
119
+ expect(result[:fitness]).to be_a(Numeric)
120
+ end
121
+
122
+ it 'returns a weaknesses array' do
123
+ result = evolver.propose_improvement(extension: low_ext)
124
+ expect(result[:weaknesses]).to be_an(Array)
125
+ end
126
+
127
+ it 'returns a suggestions array' do
128
+ begin
129
+ evolver.propose_improve(extension: low_ext)
130
+ rescue StandardError
131
+ nil
132
+ end
133
+ result = evolver.propose_improvement(extension: low_ext)
134
+ expect(result[:suggestions]).to be_an(Array)
135
+ end
136
+
137
+ it 'identifies :low_invocations weakness for zero-invocation extension' do
138
+ ext = { name: 'lex-zero', invocation_count: 0, impact_score: 0.5, error_rate: 0.0, avg_latency_ms: 0 }
139
+ result = evolver.propose_improvement(extension: ext)
140
+ expect(result[:weaknesses]).to include(:low_invocations)
141
+ end
142
+
143
+ it 'identifies :high_error_rate weakness when error_rate > 0.2' do
144
+ ext = { name: 'lex-err', invocation_count: 100, impact_score: 0.5, error_rate: 0.5, avg_latency_ms: 0 }
145
+ result = evolver.propose_improvement(extension: ext)
146
+ expect(result[:weaknesses]).to include(:high_error_rate)
147
+ end
148
+
149
+ it 'identifies :high_latency weakness when avg_latency_ms > 1000' do
150
+ ext = { name: 'lex-slow', invocation_count: 100, impact_score: 0.5, error_rate: 0.0, avg_latency_ms: 2000 }
151
+ result = evolver.propose_improvement(extension: ext)
152
+ expect(result[:weaknesses]).to include(:high_latency)
153
+ end
154
+
155
+ it 'identifies :low_impact weakness when impact_score < 0.3' do
156
+ ext = { name: 'lex-lowin', invocation_count: 100, impact_score: 0.1, error_rate: 0.0, avg_latency_ms: 0 }
157
+ result = evolver.propose_improvement(extension: ext)
158
+ expect(result[:weaknesses]).to include(:low_impact)
159
+ end
160
+
161
+ it 'returns generic suggestion when no specific weaknesses detected' do
162
+ result = evolver.propose_improvement(extension: high_ext)
163
+ expect(result[:suggestions]).not_to be_empty
164
+ end
165
+
166
+ if defined?(Legion::LLM)
167
+ it 'returns LLM-enriched suggestions when LLM is available' do
168
+ mock_llm_session = double('session')
169
+ mock_response = double('response', content: '["fix error handling", "add caching"]')
170
+ allow(mock_llm_session).to receive(:ask).and_return(mock_response)
171
+ allow(Legion::LLM).to receive(:started?).and_return(true)
172
+ allow(Legion::LLM).to receive(:chat).and_return(mock_llm_session)
173
+
174
+ result = evolver.propose_improvement(extension: low_ext)
175
+ expect(result[:suggestions]).to include('fix error handling')
176
+ end
177
+ end
178
+
179
+ it 'falls back to heuristic suggestions when LLM is unavailable' do
180
+ begin
181
+ allow(Legion).to receive(:const_defined?).with(:LLM).and_return(false)
182
+ rescue StandardError
183
+ nil
184
+ end
185
+ result = evolver.propose_improvement(extension: low_ext)
186
+ expect(result[:suggestions]).to be_an(Array)
187
+ expect(result[:suggestions]).not_to be_empty
188
+ end
189
+ end
190
+
191
+ # ─── replace_extension ─────────────────────────────────────────────────────
192
+
193
+ describe '.replace_extension' do
194
+ it 'returns success: true' do
195
+ result = evolver.replace_extension(old_name: 'lex-old', new_proposal_id: 'abc-123')
196
+ expect(result[:success]).to be true
197
+ end
198
+
199
+ it 'returns the replaced name' do
200
+ result = evolver.replace_extension(old_name: 'lex-old', new_proposal_id: 'abc-123')
201
+ expect(result[:replaced]).to eq('lex-old')
202
+ end
203
+
204
+ it 'returns the replacement_proposal_id' do
205
+ result = evolver.replace_extension(old_name: 'lex-old', new_proposal_id: 'abc-123')
206
+ expect(result[:replacement_proposal_id]).to eq('abc-123')
207
+ end
208
+
209
+ it 'marks the old extension as :pruned in the status store' do
210
+ evolver.replace_extension(old_name: 'lex-prunable', new_proposal_id: 'xyz-999')
211
+ expect(evolver.instance_variable_get(:@status_store)['lex-prunable']).to eq(:pruned)
212
+ end
213
+
214
+ it 'stores the replacement mapping' do
215
+ evolver.replace_extension(old_name: 'lex-old2', new_proposal_id: 'new-id')
216
+ expect(evolver.instance_variable_get(:@replacement_map)['lex-old2']).to eq('new-id')
217
+ end
218
+ end
219
+
220
+ # ─── merge_extensions ──────────────────────────────────────────────────────
221
+
222
+ describe '.merge_extensions' do
223
+ let(:ext_a) { { name: 'lex-alpha', category: :cognition } }
224
+ let(:ext_b) { { name: 'lex-beta', category: :memory } }
225
+
226
+ it 'returns success: true' do
227
+ result = evolver.merge_extensions(extension_a: ext_a, extension_b: ext_b)
228
+ expect(result[:success]).to be true
229
+ end
230
+
231
+ it 'returns a merged_proposal' do
232
+ result = evolver.merge_extensions(extension_a: ext_a, extension_b: ext_b)
233
+ expect(result[:merged_proposal]).to be_a(Hash)
234
+ end
235
+
236
+ it 'returns sources with both extension names' do
237
+ result = evolver.merge_extensions(extension_a: ext_a, extension_b: ext_b)
238
+ expect(result[:sources]).to contain_exactly('lex-alpha', 'lex-beta')
239
+ end
240
+
241
+ it 'calls Proposer.propose_concept to create the merged proposal' do
242
+ allow(Legion::Extensions::MindGrowth::Runners::Proposer)
243
+ .to receive(:propose_concept).and_call_original
244
+ evolver.merge_extensions(extension_a: ext_a, extension_b: ext_b)
245
+ expect(Legion::Extensions::MindGrowth::Runners::Proposer)
246
+ .to have_received(:propose_concept)
247
+ end
248
+
249
+ it 'uses provided merged_name when given' do
250
+ result = evolver.merge_extensions(extension_a: ext_a, extension_b: ext_b, merged_name: 'lex-custom')
251
+ proposal = result[:merged_proposal]
252
+ expect(proposal[:success]).to be true
253
+ end
254
+
255
+ it 'auto-generates merged name from source names when none provided' do
256
+ allow(Legion::Extensions::MindGrowth::Runners::Proposer)
257
+ .to receive(:propose_concept) do |**args|
258
+ expect(args[:name]).to include('alpha')
259
+ { success: true, proposal: { name: args[:name] } }
260
+ end
261
+ evolver.merge_extensions(extension_a: ext_a, extension_b: ext_b)
262
+ end
263
+ end
264
+
265
+ # ─── evolution_summary ─────────────────────────────────────────────────────
266
+
267
+ describe '.evolution_summary' do
268
+ let(:all_exts) { [high_ext, mid_ext, low_ext] }
269
+
270
+ it 'returns success: true' do
271
+ result = evolver.evolution_summary(extensions: all_exts)
272
+ expect(result[:success]).to be true
273
+ end
274
+
275
+ it 'returns improvement_candidates array' do
276
+ result = evolver.evolution_summary(extensions: all_exts)
277
+ expect(result[:improvement_candidates]).to be_an(Array)
278
+ end
279
+
280
+ it 'returns prune_candidates array' do
281
+ result = evolver.evolution_summary(extensions: all_exts)
282
+ expect(result[:prune_candidates]).to be_an(Array)
283
+ end
284
+
285
+ it 'returns speciation_candidates array' do
286
+ result = evolver.evolution_summary(extensions: all_exts)
287
+ expect(result[:speciation_candidates]).to be_an(Array)
288
+ end
289
+
290
+ it 'returns fitness_distribution hash with min/max/mean/median' do
291
+ result = evolver.evolution_summary(extensions: all_exts)
292
+ dist = result[:fitness_distribution]
293
+ expect(dist).to have_key(:min)
294
+ expect(dist).to have_key(:max)
295
+ expect(dist).to have_key(:mean)
296
+ expect(dist).to have_key(:median)
297
+ end
298
+
299
+ it 'fitness_distribution min <= mean <= max' do
300
+ result = evolver.evolution_summary(extensions: all_exts)
301
+ dist = result[:fitness_distribution]
302
+ expect(dist[:min]).to be <= dist[:mean]
303
+ expect(dist[:mean]).to be <= dist[:max]
304
+ end
305
+
306
+ it 'includes low-fitness extension in improvement_candidates' do
307
+ result = evolver.evolution_summary(extensions: [high_ext, low_ext], count: 1)
308
+ names = result[:improvement_candidates].map { |c| c[:name] }
309
+ expect(names).to include('lex-low')
310
+ end
311
+
312
+ it 'identifies speciation candidates by drift_score' do
313
+ drifted = { name: 'lex-drifted', drift_score: 0.8, invocation_count: 10,
314
+ impact_score: 0.5, health_score: 0.5, error_rate: 0.0, avg_latency_ms: 0 }
315
+ result = evolver.evolution_summary(extensions: [drifted])
316
+ expect(result[:speciation_candidates]).to include('lex-drifted')
317
+ end
318
+
319
+ it 'does not flag extension with drift_score below threshold as speciation candidate' do
320
+ stable = { name: 'lex-stable', drift_score: 0.1, invocation_count: 100,
321
+ impact_score: 0.7, health_score: 0.9, error_rate: 0.0, avg_latency_ms: 0 }
322
+ result = evolver.evolution_summary(extensions: [stable])
323
+ expect(result[:speciation_candidates]).to be_empty
324
+ end
325
+
326
+ it 'returns all-zero distribution for empty extensions' do
327
+ result = evolver.evolution_summary(extensions: [])
328
+ dist = result[:fitness_distribution]
329
+ expect(dist[:min]).to eq(0.0)
330
+ expect(dist[:max]).to eq(0.0)
331
+ expect(dist[:mean]).to eq(0.0)
332
+ expect(dist[:median]).to eq(0.0)
333
+ end
334
+
335
+ it 'handles all extensions having the same fitness' do
336
+ same = Array.new(3) do |i|
337
+ { name: "lex-same-#{i}", invocation_count: 0, impact_score: 0.0,
338
+ health_score: 0.0, error_rate: 0.0, avg_latency_ms: 0 }
339
+ end
340
+ result = evolver.evolution_summary(extensions: same)
341
+ dist = result[:fitness_distribution]
342
+ expect(dist[:min]).to eq(dist[:max])
343
+ end
344
+ end
345
+
346
+ # ─── constants ─────────────────────────────────────────────────────────────
347
+
348
+ describe 'constants' do
349
+ it 'defines BOTTOM_PERCENTILE as 0.05' do
350
+ expect(described_class::BOTTOM_PERCENTILE).to eq(0.05)
351
+ end
352
+
353
+ it 'defines SPECIATION_DRIFT_THRESHOLD as 0.5' do
354
+ expect(described_class::SPECIATION_DRIFT_THRESHOLD).to eq(0.5)
355
+ end
356
+ end
357
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lex-mind-growth
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.8
4
+ version: 0.1.9
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -145,7 +145,9 @@ files:
145
145
  - lib/legion/extensions/mind_growth/runners/analyzer.rb
146
146
  - lib/legion/extensions/mind_growth/runners/builder.rb
147
147
  - lib/legion/extensions/mind_growth/runners/composer.rb
148
+ - lib/legion/extensions/mind_growth/runners/dashboard.rb
148
149
  - lib/legion/extensions/mind_growth/runners/dream_ideation.rb
150
+ - lib/legion/extensions/mind_growth/runners/evolver.rb
149
151
  - lib/legion/extensions/mind_growth/runners/governance.rb
150
152
  - lib/legion/extensions/mind_growth/runners/integration_tester.rb
151
153
  - lib/legion/extensions/mind_growth/runners/monitor.rb
@@ -168,7 +170,9 @@ files:
168
170
  - spec/legion/extensions/mind_growth/runners/analyzer_spec.rb
169
171
  - spec/legion/extensions/mind_growth/runners/builder_spec.rb
170
172
  - spec/legion/extensions/mind_growth/runners/composer_spec.rb
173
+ - spec/legion/extensions/mind_growth/runners/dashboard_spec.rb
171
174
  - spec/legion/extensions/mind_growth/runners/dream_ideation_spec.rb
175
+ - spec/legion/extensions/mind_growth/runners/evolver_spec.rb
172
176
  - spec/legion/extensions/mind_growth/runners/governance_spec.rb
173
177
  - spec/legion/extensions/mind_growth/runners/integration_tester_spec.rb
174
178
  - spec/legion/extensions/mind_growth/runners/monitor_spec.rb