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.
- checksums.yaml +7 -0
- data/Gemfile +19 -0
- data/LICENSE +21 -0
- data/lex-mind-growth.gemspec +29 -0
- data/lib/legion/extensions/mind_growth/actors/growth_cycle.rb +55 -0
- data/lib/legion/extensions/mind_growth/client.rb +36 -0
- data/lib/legion/extensions/mind_growth/helpers/build_pipeline.rb +63 -0
- data/lib/legion/extensions/mind_growth/helpers/cognitive_models.rb +60 -0
- data/lib/legion/extensions/mind_growth/helpers/concept_proposal.rb +69 -0
- data/lib/legion/extensions/mind_growth/helpers/constants.rb +55 -0
- data/lib/legion/extensions/mind_growth/helpers/fitness_evaluator.rb +58 -0
- data/lib/legion/extensions/mind_growth/helpers/proposal_store.rb +71 -0
- data/lib/legion/extensions/mind_growth/runners/analyzer.rb +52 -0
- data/lib/legion/extensions/mind_growth/runners/builder.rb +254 -0
- data/lib/legion/extensions/mind_growth/runners/orchestrator.rb +178 -0
- data/lib/legion/extensions/mind_growth/runners/proposer.rb +269 -0
- data/lib/legion/extensions/mind_growth/runners/validator.rb +57 -0
- data/lib/legion/extensions/mind_growth/version.rb +9 -0
- data/lib/legion/extensions/mind_growth.rb +25 -0
- data/spec/legion/extensions/mind_growth/actors/growth_cycle_spec.rb +81 -0
- data/spec/legion/extensions/mind_growth/client_spec.rb +111 -0
- data/spec/legion/extensions/mind_growth/helpers/build_pipeline_spec.rb +190 -0
- data/spec/legion/extensions/mind_growth/helpers/cognitive_models_spec.rb +106 -0
- data/spec/legion/extensions/mind_growth/helpers/concept_proposal_spec.rb +209 -0
- data/spec/legion/extensions/mind_growth/helpers/fitness_evaluator_spec.rb +123 -0
- data/spec/legion/extensions/mind_growth/helpers/proposal_store_spec.rb +203 -0
- data/spec/legion/extensions/mind_growth/runners/analyzer_spec.rb +108 -0
- data/spec/legion/extensions/mind_growth/runners/builder_spec.rb +347 -0
- data/spec/legion/extensions/mind_growth/runners/orchestrator_spec.rb +185 -0
- data/spec/legion/extensions/mind_growth/runners/proposer_spec.rb +493 -0
- data/spec/legion/extensions/mind_growth/runners/validator_spec.rb +120 -0
- data/spec/spec_helper.rb +22 -0
- 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
|