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