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,185 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::MindGrowth::Runners::Orchestrator do
4
+ subject(:orchestrator) { described_class }
5
+
6
+ before { Legion::Extensions::MindGrowth::Runners::Proposer.instance_variable_set(:@proposal_store, nil) }
7
+
8
+ describe '.run_growth_cycle' do
9
+ it 'returns success: true with a trace' do
10
+ result = orchestrator.run_growth_cycle
11
+ expect(result[:success]).to be true
12
+ expect(result[:trace]).to be_a(Hash)
13
+ end
14
+
15
+ it 'trace includes started_at and completed_at' do
16
+ result = orchestrator.run_growth_cycle
17
+ expect(result[:trace][:started_at]).to be_a(Time)
18
+ expect(result[:trace][:completed_at]).to be_a(Time)
19
+ end
20
+
21
+ it 'trace includes duration_ms' do
22
+ result = orchestrator.run_growth_cycle
23
+ expect(result[:trace][:duration_ms]).to be_a(Integer)
24
+ expect(result[:trace][:duration_ms]).to be >= 0
25
+ end
26
+
27
+ it 'trace has analyze, propose, evaluate, and build steps' do
28
+ result = orchestrator.run_growth_cycle
29
+ step_names = result[:trace][:steps].map { |s| s[:step] }
30
+ expect(step_names).to include(:analyze, :propose, :evaluate, :build)
31
+ end
32
+
33
+ it 'creates proposals from gap analysis' do
34
+ result = orchestrator.run_growth_cycle
35
+ propose_step = result[:trace][:steps].find { |s| s[:step] == :propose }
36
+ expect(propose_step[:count]).to be > 0
37
+ end
38
+
39
+ it 'evaluates proposals' do
40
+ result = orchestrator.run_growth_cycle
41
+ eval_step = result[:trace][:steps].find { |s| s[:step] == :evaluate }
42
+ expect(eval_step[:evaluated]).to be > 0
43
+ end
44
+
45
+ it 'builds approved proposals when forced' do
46
+ result = orchestrator.run_growth_cycle(force: true)
47
+ build_step = result[:trace][:steps].find { |s| s[:step] == :build }
48
+ expect(build_step[:attempted]).to be > 0
49
+ end
50
+
51
+ it 'respects max_proposals parameter' do
52
+ result = orchestrator.run_growth_cycle(max_proposals: 1)
53
+ propose_step = result[:trace][:steps].find { |s| s[:step] == :propose }
54
+ expect(propose_step[:count]).to eq(1)
55
+ end
56
+
57
+ it 'accepts existing_extensions parameter' do
58
+ exts = %i[memory emotion prediction trust consent identity]
59
+ result = orchestrator.run_growth_cycle(existing_extensions: exts, max_proposals: 1)
60
+ expect(result[:success]).to be true
61
+ end
62
+
63
+ it 'accepts base_path parameter' do
64
+ result = orchestrator.run_growth_cycle(max_proposals: 1, base_path: '/tmp')
65
+ expect(result[:success]).to be true
66
+ end
67
+
68
+ it 'ignores unknown keyword arguments' do
69
+ expect { orchestrator.run_growth_cycle(max_proposals: 1, unknown: :val) }.not_to raise_error
70
+ end
71
+
72
+ it 'builds succeed in stub mode (no codegen/exec loaded)' do
73
+ result = orchestrator.run_growth_cycle(max_proposals: 1, force: true)
74
+ build_step = result[:trace][:steps].find { |s| s[:step] == :build }
75
+ expect(build_step[:attempted]).to be > 0
76
+ expect(build_step[:succeeded]).to eq(build_step[:attempted])
77
+ end
78
+
79
+ it 'assigns categories to proposals based on requirement mapping' do
80
+ result = orchestrator.run_growth_cycle(max_proposals: 1)
81
+ proposal_step = result[:trace][:steps].find { |s| s[:step] == :propose }
82
+ proposal_id = proposal_step[:proposals].first
83
+ proposal = Legion::Extensions::MindGrowth::Runners::Proposer.get_proposal_object(proposal_id)
84
+ expected_cats = Legion::Extensions::MindGrowth::Helpers::Constants::CATEGORIES
85
+ expect(expected_cats).to include(proposal.category)
86
+ end
87
+
88
+ context 'when all extensions are covered' do
89
+ it 'returns failure when no priorities found' do
90
+ # Provide all extensions needed by all models to eliminate gaps
91
+ all_exts = %i[
92
+ attention global_workspace broadcasting working_memory consciousness
93
+ prediction free_energy predictive_coding belief_revision active_inference error_monitoring
94
+ intuition dual_process inhibition executive_function cognitive_control
95
+ emotion somatic_marker interoception appraisal embodied_simulation
96
+ episodic_buffer cognitive_load
97
+ ]
98
+ result = orchestrator.run_growth_cycle(existing_extensions: all_exts)
99
+ expect(result[:success]).to be false
100
+ expect(result[:error]).to include('no priorities')
101
+ end
102
+ end
103
+
104
+ context 'auto-approve governance gate' do
105
+ it 'holds proposals with default scores for governance review' do
106
+ result = orchestrator.run_growth_cycle(max_proposals: 1)
107
+ build_step = result[:trace][:steps].find { |s| s[:step] == :build }
108
+ expect(build_step[:held]).to be > 0
109
+ expect(build_step[:attempted]).to eq(0)
110
+ end
111
+
112
+ it 'reports held proposals in the build step message' do
113
+ result = orchestrator.run_growth_cycle(max_proposals: 1)
114
+ build_step = result[:trace][:steps].find { |s| s[:step] == :build }
115
+ expect(build_step[:message]).to include('governance review')
116
+ end
117
+
118
+ it 'still succeeds when proposals are held' do
119
+ result = orchestrator.run_growth_cycle(max_proposals: 1)
120
+ expect(result[:success]).to be true
121
+ end
122
+
123
+ it 'evaluate step tracks auto_approved and held_for_review counts' do
124
+ result = orchestrator.run_growth_cycle(max_proposals: 1)
125
+ eval_step = result[:trace][:steps].find { |s| s[:step] == :evaluate }
126
+ expect(eval_step).to have_key(:auto_approved)
127
+ expect(eval_step).to have_key(:held_for_review)
128
+ # Default 0.7 scores: none auto-approved, all held
129
+ expect(eval_step[:auto_approved]).to eq(0)
130
+ expect(eval_step[:held_for_review]).to be > 0
131
+ end
132
+
133
+ it 'builds all approved proposals when force: true' do
134
+ result = orchestrator.run_growth_cycle(max_proposals: 1, force: true)
135
+ build_step = result[:trace][:steps].find { |s| s[:step] == :build }
136
+ expect(build_step[:attempted]).to be > 0
137
+ expect(build_step[:held]).to eq(0)
138
+ end
139
+
140
+ it 'auto-builds proposals with scores above auto-approve threshold' do
141
+ # Run a cycle to create proposals, then manually set high scores
142
+ proposer = Legion::Extensions::MindGrowth::Runners::Proposer
143
+ proposal_result = proposer.propose_concept(
144
+ name: 'lex-auto-test', category: :cognition,
145
+ description: 'Test auto-approve', enrich: false
146
+ )
147
+ high_scores = Legion::Extensions::MindGrowth::Helpers::Constants::EVALUATION_DIMENSIONS.to_h { |d| [d, 0.95] }
148
+ eval_result = proposer.evaluate_proposal(proposal_id: proposal_result[:proposal][:id], scores: high_scores)
149
+ expect(eval_result[:auto_approved]).to be true
150
+
151
+ # Now run a fresh cycle — the manually created proposal won't be part of it,
152
+ # but we verify the auto_approvable? mechanism works
153
+ expect(eval_result[:approved]).to be true
154
+ end
155
+ end
156
+ end
157
+
158
+ describe '.growth_status' do
159
+ it 'returns success: true' do
160
+ result = orchestrator.growth_status
161
+ expect(result[:success]).to be true
162
+ end
163
+
164
+ it 'includes proposal stats' do
165
+ result = orchestrator.growth_status
166
+ expect(result[:proposals]).to be_a(Hash)
167
+ end
168
+
169
+ it 'includes coverage score' do
170
+ result = orchestrator.growth_status
171
+ expect(result[:coverage]).to be_a(Float)
172
+ end
173
+
174
+ it 'includes model coverage details' do
175
+ result = orchestrator.growth_status
176
+ expect(result[:model_coverage]).to be_an(Array)
177
+ end
178
+
179
+ it 'reflects proposals after a growth cycle' do
180
+ orchestrator.run_growth_cycle(max_proposals: 2)
181
+ result = orchestrator.growth_status
182
+ expect(result[:proposals][:total]).to be >= 2
183
+ end
184
+ end
185
+ end
@@ -0,0 +1,493 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::MindGrowth::Runners::Proposer do
4
+ subject(:proposer) { described_class }
5
+
6
+ # Reset the memoized proposal store between examples
7
+ before { proposer.instance_variable_set(:@proposal_store, nil) }
8
+
9
+ describe '.analyze_gaps' do
10
+ it 'returns success: true' do
11
+ result = proposer.analyze_gaps
12
+ expect(result[:success]).to be true
13
+ end
14
+
15
+ it 'returns models array' do
16
+ result = proposer.analyze_gaps
17
+ expect(result[:models]).to be_an(Array)
18
+ end
19
+
20
+ it 'returns recommendations array' do
21
+ result = proposer.analyze_gaps
22
+ expect(result[:recommendations]).to be_an(Array)
23
+ end
24
+
25
+ it 'accepts existing_extensions parameter' do
26
+ result = proposer.analyze_gaps(existing_extensions: %i[attention memory])
27
+ expect(result[:success]).to be true
28
+ end
29
+
30
+ it 'limits recommendations to 10' do
31
+ result = proposer.analyze_gaps(existing_extensions: [])
32
+ expect(result[:recommendations].size).to be <= 10
33
+ end
34
+
35
+ it 'ignores unknown keyword arguments' do
36
+ expect { proposer.analyze_gaps(unknown_key: true) }.not_to raise_error
37
+ end
38
+ end
39
+
40
+ describe '.propose_concept' do
41
+ it 'returns success: true' do
42
+ result = proposer.propose_concept(name: 'lex-test', category: :cognition, description: 'test')
43
+ expect(result[:success]).to be true
44
+ end
45
+
46
+ it 'includes proposal hash' do
47
+ result = proposer.propose_concept(name: 'lex-test', category: :cognition, description: 'test')
48
+ expect(result[:proposal]).to be_a(Hash)
49
+ expect(result[:proposal][:id]).to be_a(String)
50
+ end
51
+
52
+ it 'uses provided name' do
53
+ result = proposer.propose_concept(name: 'lex-custom', category: :cognition, description: 'test')
54
+ expect(result[:proposal][:name]).to eq('lex-custom')
55
+ end
56
+
57
+ it 'generates a name when none provided' do
58
+ result = proposer.propose_concept(category: :cognition, description: 'test')
59
+ expect(result[:proposal][:name]).to start_with('lex-')
60
+ end
61
+
62
+ it 'uses provided category' do
63
+ result = proposer.propose_concept(name: 'lex-cat', category: :safety, description: 'test')
64
+ expect(result[:proposal][:category]).to eq(:safety)
65
+ end
66
+
67
+ it 'falls back to suggested category when none given' do
68
+ result = proposer.propose_concept(description: 'auto category')
69
+ expect(Legion::Extensions::MindGrowth::Helpers::Constants::CATEGORIES).to include(result[:proposal][:category])
70
+ end
71
+
72
+ it 'suggests the most underrepresented category based on target distribution' do
73
+ # With no proposals, cognition has the highest target (0.30) so it gets suggested
74
+ result = proposer.propose_concept(description: 'first auto')
75
+ expect(result[:proposal][:category]).to eq(:cognition)
76
+ end
77
+
78
+ it 'shifts suggestion after filling a category' do
79
+ 3.times { proposer.propose_concept(category: :cognition, description: 'cog') }
80
+ result = proposer.propose_concept(description: 'next auto')
81
+ # cognition is now overrepresented relative to target, so it should suggest something else
82
+ expect(result[:proposal][:category]).not_to eq(:cognition)
83
+ end
84
+
85
+ it 'stores proposal in the proposal store' do
86
+ result = proposer.propose_concept(name: 'lex-stored', category: :cognition, description: 'test')
87
+ id = result[:proposal][:id]
88
+ stats = proposer.proposal_stats
89
+ expect(stats[:stats][:total]).to eq(1)
90
+ get_result = proposer.list_proposals
91
+ expect(get_result[:proposals].map { |p| p[:id] }).to include(id)
92
+ end
93
+
94
+ it 'derives module_name correctly from lex- prefixed name' do
95
+ result = proposer.propose_concept(name: 'lex-working-memory', category: :memory, description: 'test')
96
+ expect(result[:proposal][:module_name]).to eq('WorkingMemory')
97
+ end
98
+
99
+ it 'derives module_name from non-lex name' do
100
+ result = proposer.propose_concept(name: 'emotion-engine', category: :cognition, description: 'test')
101
+ expect(result[:proposal][:module_name]).to eq('EmotionEngine')
102
+ end
103
+
104
+ it 'derives module_name for generated names' do
105
+ result = proposer.propose_concept(category: :cognition, description: 'auto named')
106
+ expect(result[:proposal][:module_name]).to be_a(String)
107
+ expect(result[:proposal][:module_name]).not_to include('-')
108
+ end
109
+
110
+ context 'with LLM enrichment' do
111
+ let(:mock_chat) { double('RubyLLM::Chat') }
112
+ let(:enrichment_json) do
113
+ {
114
+ metaphor: 'like a garden growing knowledge',
115
+ rationale: 'fills the working memory gap',
116
+ helpers: [{ name: 'store', methods: [{ name: 'add', params: %w[key value] }] }],
117
+ runner_methods: [{ name: 'update', params: ['tick_results'], returns: 'status hash' }]
118
+ }.to_json
119
+ end
120
+ let(:mock_response) { double('RubyLLM::Message', content: enrichment_json) }
121
+
122
+ before do
123
+ llm_mod = Module.new do
124
+ def self.started? = true
125
+ def self.chat(**) = nil
126
+ end
127
+ stub_const('Legion::LLM', llm_mod)
128
+ allow(Legion::LLM).to receive(:chat).and_return(mock_chat)
129
+ allow(mock_chat).to receive(:ask).and_return(mock_response)
130
+ end
131
+
132
+ it 'populates helpers from LLM response' do
133
+ result = proposer.propose_concept(name: 'lex-enriched', category: :cognition, description: 'enriched')
134
+ expect(result[:proposal][:helpers]).not_to be_empty
135
+ expect(result[:proposal][:helpers].first[:name]).to eq('store')
136
+ end
137
+
138
+ it 'populates runner_methods from LLM response' do
139
+ result = proposer.propose_concept(name: 'lex-enriched', category: :cognition, description: 'enriched')
140
+ expect(result[:proposal][:runner_methods]).not_to be_empty
141
+ expect(result[:proposal][:runner_methods].first[:name]).to eq('update')
142
+ end
143
+
144
+ it 'populates metaphor from LLM response' do
145
+ result = proposer.propose_concept(name: 'lex-enriched', category: :cognition, description: 'enriched')
146
+ expect(result[:proposal][:metaphor]).to eq('like a garden growing knowledge')
147
+ end
148
+
149
+ it 'populates rationale from LLM response' do
150
+ result = proposer.propose_concept(name: 'lex-enriched', category: :cognition, description: 'enriched')
151
+ expect(result[:proposal][:rationale]).to eq('fills the working memory gap')
152
+ end
153
+
154
+ it 'handles LLM errors gracefully' do
155
+ allow(mock_chat).to receive(:ask).and_raise(StandardError, 'timeout')
156
+ result = proposer.propose_concept(name: 'lex-fallback', category: :cognition, description: 'test')
157
+ expect(result[:success]).to be true
158
+ expect(result[:proposal][:helpers]).to eq([])
159
+ end
160
+
161
+ it 'handles malformed JSON gracefully' do
162
+ allow(mock_response).to receive(:content).and_return('not json at all')
163
+ result = proposer.propose_concept(name: 'lex-malformed', category: :cognition, description: 'test')
164
+ expect(result[:success]).to be true
165
+ expect(result[:proposal][:helpers]).to eq([])
166
+ end
167
+
168
+ it 'extracts JSON from markdown fences' do
169
+ fenced = "```json\n#{enrichment_json}\n```"
170
+ allow(mock_response).to receive(:content).and_return(fenced)
171
+ result = proposer.propose_concept(name: 'lex-fenced', category: :cognition, description: 'test')
172
+ expect(result[:proposal][:helpers]).not_to be_empty
173
+ end
174
+ end
175
+
176
+ context 'without LLM' do
177
+ it 'creates proposal with empty helpers when enrich: true but no LLM' do
178
+ result = proposer.propose_concept(name: 'lex-nollm', category: :cognition, description: 'test')
179
+ expect(result[:success]).to be true
180
+ expect(result[:proposal][:helpers]).to eq([])
181
+ end
182
+
183
+ it 'skips enrichment when enrich: false' do
184
+ result = proposer.propose_concept(name: 'lex-noenrich', category: :cognition, description: 'test', enrich: false)
185
+ expect(result[:success]).to be true
186
+ expect(result[:proposal][:helpers]).to eq([])
187
+ end
188
+ end
189
+
190
+ context 'redundancy checking' do
191
+ it 'rejects exact name duplicates' do
192
+ proposer.propose_concept(name: 'lex-duplicate', category: :cognition, description: 'first')
193
+ result = proposer.propose_concept(name: 'lex-duplicate', category: :cognition, description: 'second')
194
+ expect(result[:success]).to be false
195
+ expect(result[:error]).to eq(:redundant)
196
+ expect(result[:similar_to]).to eq('lex-duplicate')
197
+ expect(result[:score]).to eq(1.0)
198
+ end
199
+
200
+ it 'allows proposals with different names without LLM' do
201
+ proposer.propose_concept(name: 'lex-alpha', category: :cognition, description: 'first')
202
+ result = proposer.propose_concept(name: 'lex-beta', category: :cognition, description: 'second')
203
+ expect(result[:success]).to be true
204
+ end
205
+
206
+ it 'allows first proposal without checking' do
207
+ result = proposer.propose_concept(name: 'lex-first', category: :cognition, description: 'first')
208
+ expect(result[:success]).to be true
209
+ end
210
+
211
+ context 'with LLM redundancy checking' do
212
+ let(:mock_chat) { double('RubyLLM::Chat') }
213
+
214
+ before do
215
+ llm_mod = Module.new do
216
+ def self.started? = true
217
+ def self.chat(**) = nil
218
+ end
219
+ stub_const('Legion::LLM', llm_mod)
220
+ allow(Legion::LLM).to receive(:chat).and_return(mock_chat)
221
+ end
222
+
223
+ it 'rejects semantically redundant proposals' do
224
+ proposer.propose_concept(name: 'lex-working-mem', category: :memory, description: 'working memory management', enrich: false)
225
+ redundancy_json = { redundant: true, similar_to: 'lex-working-mem', score: 0.92 }.to_json
226
+ allow(mock_chat).to receive(:ask).and_return(double(content: redundancy_json))
227
+
228
+ result = proposer.propose_concept(name: 'lex-work-memory', category: :memory, description: 'manages working memory')
229
+ expect(result[:success]).to be false
230
+ expect(result[:error]).to eq(:redundant)
231
+ expect(result[:similar_to]).to eq('lex-working-mem')
232
+ expect(result[:score]).to eq(0.92)
233
+ end
234
+
235
+ it 'allows non-redundant proposals detected by LLM' do
236
+ proposer.propose_concept(name: 'lex-existing', category: :cognition, description: 'existing extension', enrich: false)
237
+ not_redundant_json = { redundant: false, similar_to: nil, score: 0.3 }.to_json
238
+ allow(mock_chat).to receive(:ask).and_return(double(content: not_redundant_json))
239
+
240
+ result = proposer.propose_concept(name: 'lex-novel', category: :memory, description: 'totally novel', enrich: false)
241
+ expect(result[:success]).to be true
242
+ end
243
+
244
+ it 'uses REDUNDANCY_THRESHOLD for the cutoff' do
245
+ proposer.propose_concept(name: 'lex-base', category: :cognition, description: 'base extension', enrich: false)
246
+ below_threshold = { redundant: false, similar_to: 'lex-base', score: 0.79 }.to_json
247
+ allow(mock_chat).to receive(:ask).and_return(double(content: below_threshold))
248
+
249
+ result = proposer.propose_concept(name: 'lex-similar', category: :cognition, description: 'similar', enrich: false)
250
+ expect(result[:success]).to be true
251
+ end
252
+
253
+ it 'falls back to non-redundant when LLM returns malformed JSON' do
254
+ proposer.propose_concept(name: 'lex-anchor', category: :cognition, description: 'anchor', enrich: false)
255
+ allow(mock_chat).to receive(:ask).and_return(double(content: 'not json'))
256
+
257
+ result = proposer.propose_concept(name: 'lex-different', category: :memory, description: 'different', enrich: false)
258
+ expect(result[:success]).to be true
259
+ end
260
+
261
+ it 'falls back to non-redundant when LLM raises an error' do
262
+ proposer.propose_concept(name: 'lex-safe', category: :cognition, description: 'safe', enrich: false)
263
+ allow(mock_chat).to receive(:ask).and_raise(StandardError, 'timeout')
264
+
265
+ result = proposer.propose_concept(name: 'lex-new', category: :memory, description: 'new', enrich: false)
266
+ expect(result[:success]).to be true
267
+ end
268
+
269
+ it 'includes existing proposals in the redundancy prompt' do
270
+ proposer.propose_concept(name: 'lex-ctx-test', category: :cognition, description: 'context test', enrich: false)
271
+ not_redundant = { redundant: false, similar_to: nil, score: 0.1 }.to_json
272
+ allow(mock_chat).to receive(:ask).and_return(double(content: not_redundant))
273
+
274
+ proposer.propose_concept(name: 'lex-check-ctx', category: :memory, description: 'check', enrich: false)
275
+ expect(mock_chat).to have_received(:ask).with(a_string_including('lex-ctx-test'))
276
+ expect(mock_chat).to have_received(:ask).with(a_string_including('context test'))
277
+ end
278
+
279
+ it 'parses redundancy from markdown-fenced JSON' do
280
+ proposer.propose_concept(name: 'lex-fenced-base', category: :cognition, description: 'base', enrich: false)
281
+ fenced = "```json\n#{{ redundant: true, similar_to: 'lex-fenced-base', score: 0.85 }.to_json}\n```"
282
+ allow(mock_chat).to receive(:ask).and_return(double(content: fenced))
283
+
284
+ result = proposer.propose_concept(name: 'lex-fenced-dup', category: :cognition, description: 'dup')
285
+ expect(result[:success]).to be false
286
+ expect(result[:error]).to eq(:redundant)
287
+ end
288
+
289
+ it 'clamps redundancy score to 0.0-1.0' do
290
+ proposer.propose_concept(name: 'lex-clamp-base', category: :cognition, description: 'base', enrich: false)
291
+ clamped = { redundant: true, similar_to: 'lex-clamp-base', score: 1.5 }.to_json
292
+ allow(mock_chat).to receive(:ask).and_return(double(content: clamped))
293
+
294
+ result = proposer.propose_concept(name: 'lex-clamp-new', category: :cognition, description: 'new')
295
+ expect(result[:success]).to be false
296
+ expect(result[:score]).to eq(1.0)
297
+ end
298
+
299
+ it 'skips LLM check for exact name match (efficiency)' do
300
+ proposer.propose_concept(name: 'lex-exact', category: :cognition, description: 'original', enrich: false)
301
+ allow(mock_chat).to receive(:ask)
302
+ result = proposer.propose_concept(name: 'lex-exact', category: :memory, description: 'different desc')
303
+ expect(result[:error]).to eq(:redundant)
304
+ expect(mock_chat).not_to have_received(:ask)
305
+ end
306
+ end
307
+ end
308
+ end
309
+
310
+ describe '.evaluate_proposal' do
311
+ let!(:proposal_id) do
312
+ result = proposer.propose_concept(name: 'lex-eval', category: :cognition, description: 'to evaluate')
313
+ result[:proposal][:id]
314
+ end
315
+
316
+ it 'returns not_found for unknown id' do
317
+ result = proposer.evaluate_proposal(proposal_id: 'nonexistent')
318
+ expect(result[:success]).to be false
319
+ expect(result[:error]).to eq(:not_found)
320
+ end
321
+
322
+ it 'evaluates with default scores' do
323
+ result = proposer.evaluate_proposal(proposal_id: proposal_id)
324
+ expect(result[:success]).to be true
325
+ end
326
+
327
+ it 'evaluates with provided scores' do
328
+ scores = Legion::Extensions::MindGrowth::Helpers::Constants::EVALUATION_DIMENSIONS.to_h { |d| [d, 0.8] }
329
+ result = proposer.evaluate_proposal(proposal_id: proposal_id, scores: scores)
330
+ expect(result[:success]).to be true
331
+ expect(result[:approved]).to be true
332
+ end
333
+
334
+ it 'rejects when scores are below threshold' do
335
+ scores = Legion::Extensions::MindGrowth::Helpers::Constants::EVALUATION_DIMENSIONS.to_h { |d| [d, 0.4] }
336
+ result = proposer.evaluate_proposal(proposal_id: proposal_id, scores: scores)
337
+ expect(result[:approved]).to be false
338
+ end
339
+
340
+ it 'returns proposal hash' do
341
+ result = proposer.evaluate_proposal(proposal_id: proposal_id)
342
+ expect(result[:proposal]).to be_a(Hash)
343
+ end
344
+
345
+ it 'returns auto_approved: false with default scores' do
346
+ result = proposer.evaluate_proposal(proposal_id: proposal_id)
347
+ expect(result[:auto_approved]).to be false
348
+ end
349
+
350
+ it 'returns auto_approved: true when all scores meet auto-approve threshold' do
351
+ high = Legion::Extensions::MindGrowth::Helpers::Constants::EVALUATION_DIMENSIONS.to_h { |d| [d, 0.95] }
352
+ result = proposer.evaluate_proposal(proposal_id: proposal_id, scores: high)
353
+ expect(result[:auto_approved]).to be true
354
+ end
355
+
356
+ it 'returns auto_approved: false when any score is below auto-approve threshold' do
357
+ mixed = Legion::Extensions::MindGrowth::Helpers::Constants::EVALUATION_DIMENSIONS.to_h { |d| [d, 0.95] }
358
+ mixed[:fit] = 0.85
359
+ result = proposer.evaluate_proposal(proposal_id: proposal_id, scores: mixed)
360
+ expect(result[:approved]).to be true
361
+ expect(result[:auto_approved]).to be false
362
+ end
363
+
364
+ context 'with LLM scoring' do
365
+ let(:mock_chat) { double('RubyLLM::Chat') }
366
+ let(:score_json) do
367
+ { novelty: 0.85, fit: 0.75, cognitive_value: 0.80, implementability: 0.90, composability: 0.70 }.to_json
368
+ end
369
+ let(:mock_response) { double('RubyLLM::Message', content: score_json) }
370
+
371
+ before do
372
+ llm_mod = Module.new do
373
+ def self.started? = true
374
+ def self.chat(**) = nil
375
+ end
376
+ stub_const('Legion::LLM', llm_mod)
377
+ allow(Legion::LLM).to receive(:chat).and_return(mock_chat)
378
+ allow(mock_chat).to receive(:ask).and_return(mock_response)
379
+ end
380
+
381
+ it 'uses LLM scores when no explicit scores provided' do
382
+ result = proposer.evaluate_proposal(proposal_id: proposal_id)
383
+ expect(result[:success]).to be true
384
+ expect(result[:proposal][:scores][:novelty]).to eq(0.85)
385
+ expect(result[:proposal][:scores][:fit]).to eq(0.75)
386
+ end
387
+
388
+ it 'approves when LLM scores are above threshold' do
389
+ result = proposer.evaluate_proposal(proposal_id: proposal_id)
390
+ expect(result[:approved]).to be true
391
+ end
392
+
393
+ it 'rejects when LLM scores are below threshold' do
394
+ low_scores = { novelty: 0.3, fit: 0.4, cognitive_value: 0.5, implementability: 0.2, composability: 0.1 }.to_json
395
+ allow(mock_response).to receive(:content).and_return(low_scores)
396
+ result = proposer.evaluate_proposal(proposal_id: proposal_id)
397
+ expect(result[:approved]).to be false
398
+ end
399
+
400
+ it 'clamps scores to 0.0-1.0 range' do
401
+ out_of_range = { novelty: 1.5, fit: -0.3, cognitive_value: 0.8, implementability: 2.0, composability: 0.7 }.to_json
402
+ allow(mock_response).to receive(:content).and_return(out_of_range)
403
+ result = proposer.evaluate_proposal(proposal_id: proposal_id)
404
+ expect(result[:proposal][:scores][:novelty]).to eq(1.0)
405
+ expect(result[:proposal][:scores][:fit]).to eq(0.0)
406
+ expect(result[:proposal][:scores][:implementability]).to eq(1.0)
407
+ end
408
+
409
+ it 'prefers explicit scores over LLM scores' do
410
+ explicit = Legion::Extensions::MindGrowth::Helpers::Constants::EVALUATION_DIMENSIONS.to_h { |d| [d, 0.65] }
411
+ result = proposer.evaluate_proposal(proposal_id: proposal_id, scores: explicit)
412
+ expect(result[:proposal][:scores][:novelty]).to eq(0.65)
413
+ expect(mock_chat).not_to have_received(:ask)
414
+ end
415
+
416
+ it 'falls back to defaults when LLM returns malformed JSON' do
417
+ allow(mock_response).to receive(:content).and_return('not valid json')
418
+ result = proposer.evaluate_proposal(proposal_id: proposal_id)
419
+ expect(result[:success]).to be true
420
+ expect(result[:proposal][:scores][:novelty]).to eq(0.7)
421
+ end
422
+
423
+ it 'falls back to defaults when LLM returns incomplete scores' do
424
+ partial = { novelty: 0.8, fit: 0.7 }.to_json
425
+ allow(mock_response).to receive(:content).and_return(partial)
426
+ result = proposer.evaluate_proposal(proposal_id: proposal_id)
427
+ expect(result[:proposal][:scores][:novelty]).to eq(0.7)
428
+ end
429
+
430
+ it 'falls back to defaults when LLM raises an error' do
431
+ allow(mock_chat).to receive(:ask).and_raise(StandardError, 'timeout')
432
+ result = proposer.evaluate_proposal(proposal_id: proposal_id)
433
+ expect(result[:success]).to be true
434
+ expect(result[:proposal][:scores][:novelty]).to eq(0.7)
435
+ end
436
+
437
+ it 'parses scores from markdown-fenced JSON' do
438
+ fenced = "```json\n#{score_json}\n```"
439
+ allow(mock_response).to receive(:content).and_return(fenced)
440
+ result = proposer.evaluate_proposal(proposal_id: proposal_id)
441
+ expect(result[:proposal][:scores][:novelty]).to eq(0.85)
442
+ end
443
+
444
+ it 'includes proposal context in the scoring prompt' do
445
+ proposer.evaluate_proposal(proposal_id: proposal_id)
446
+ expect(mock_chat).to have_received(:ask).with(a_string_including('lex-eval'))
447
+ expect(mock_chat).to have_received(:ask).with(a_string_including('to evaluate'))
448
+ expect(mock_chat).to have_received(:ask).with(a_string_including('cognition'))
449
+ end
450
+ end
451
+ end
452
+
453
+ describe '.list_proposals' do
454
+ it 'returns success: true with empty store' do
455
+ result = proposer.list_proposals
456
+ expect(result[:success]).to be true
457
+ expect(result[:proposals]).to be_an(Array)
458
+ end
459
+
460
+ it 'returns all proposals when no status filter' do
461
+ proposer.propose_concept(name: 'lex-a', category: :cognition, description: 'a')
462
+ proposer.propose_concept(name: 'lex-b', category: :memory, description: 'b')
463
+ result = proposer.list_proposals
464
+ expect(result[:count]).to eq(2)
465
+ end
466
+
467
+ it 'filters by status' do
468
+ proposer.propose_concept(name: 'lex-filter', category: :cognition, description: 'filter')
469
+ result = proposer.list_proposals(status: :proposed)
470
+ expect(result[:proposals]).not_to be_empty
471
+ result[:proposals].each do |p|
472
+ expect(p[:status]).to eq(:proposed)
473
+ end
474
+ end
475
+
476
+ it 'respects limit parameter' do
477
+ 5.times { |i| proposer.propose_concept(name: "lex-lim-#{i}", category: :cognition, description: "limit #{i}") }
478
+ result = proposer.list_proposals(limit: 3)
479
+ expect(result[:proposals].size).to be <= 3
480
+ end
481
+ end
482
+
483
+ describe '.proposal_stats' do
484
+ it 'returns success: true' do
485
+ expect(proposer.proposal_stats[:success]).to be true
486
+ end
487
+
488
+ it 'includes total count' do
489
+ proposer.propose_concept(name: 'lex-stat', category: :cognition, description: 'stat')
490
+ expect(proposer.proposal_stats[:stats][:total]).to eq(1)
491
+ end
492
+ end
493
+ end