lex-mind-growth 0.1.6 → 0.1.7
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 +4 -4
- data/lib/legion/extensions/mind_growth/client.rb +12 -0
- data/lib/legion/extensions/mind_growth/helpers/constants.rb +14 -0
- data/lib/legion/extensions/mind_growth/runners/governance.rb +122 -0
- data/lib/legion/extensions/mind_growth/runners/risk_assessor.rb +106 -0
- data/lib/legion/extensions/mind_growth/version.rb +1 -1
- data/lib/legion/extensions/mind_growth.rb +2 -0
- data/spec/legion/extensions/mind_growth/runners/governance_spec.rb +439 -0
- data/spec/legion/extensions/mind_growth/runners/risk_assessor_spec.rb +395 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2182f87f0f59f727c61f209083c225b1b31d9cbb5589af437618350433e8b692
|
|
4
|
+
data.tar.gz: 1ee0428c83686efdb168054b16d68af66443dfa60bd5f0bd8e2725f4d7d87ab4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1e7320c15ecd31d531bd961b03f20baa8c30a77946f98e58c25557ebcd1027b42b7c9af7c9ce8ae781089e25966a76d183209922ce8b2e7a780c141853cf6d96
|
|
7
|
+
data.tar.gz: b7abcbc0ea3109d64a4cc1cda9508ea0ee8ab777271e205abc1ae4903d1f2bf349b78053aa071fe5835790a790f4974be95800cf57ad2bfa8ab96182b358d312
|
|
@@ -35,6 +35,18 @@ module Legion
|
|
|
35
35
|
def session_report(**) = Runners::Retrospective.session_report(**)
|
|
36
36
|
def trend_analysis(**) = Runners::Retrospective.trend_analysis(**)
|
|
37
37
|
def learning_extraction(**) = Runners::Retrospective.learning_extraction(**)
|
|
38
|
+
|
|
39
|
+
# Governance delegation
|
|
40
|
+
def submit_proposal(**) = Runners::Governance.submit_proposal(**)
|
|
41
|
+
def vote_on_proposal(**) = Runners::Governance.vote_on_proposal(**)
|
|
42
|
+
def tally_votes(**) = Runners::Governance.tally_votes(**)
|
|
43
|
+
def approve_proposal(**) = Runners::Governance.approve_proposal(**)
|
|
44
|
+
def reject_proposal(**) = Runners::Governance.reject_proposal(**)
|
|
45
|
+
def governance_stats(**) = Runners::Governance.governance_stats(**)
|
|
46
|
+
|
|
47
|
+
# RiskAssessor delegation
|
|
48
|
+
def assess_risk(**) = Runners::RiskAssessor.assess_risk(**)
|
|
49
|
+
def risk_summary(**) = Runners::RiskAssessor.risk_summary(**)
|
|
38
50
|
end
|
|
39
51
|
end
|
|
40
52
|
end
|
|
@@ -48,6 +48,20 @@ module Legion
|
|
|
48
48
|
|
|
49
49
|
# Reference cognitive models
|
|
50
50
|
COGNITIVE_MODELS = %i[global_workspace free_energy dual_process somatic_marker working_memory].freeze
|
|
51
|
+
|
|
52
|
+
# Governance
|
|
53
|
+
QUORUM = 3
|
|
54
|
+
REJECTION_COOLDOWN_HOURS = 24
|
|
55
|
+
GOVERNANCE_STATUSES = %i[pending approved rejected expired].freeze
|
|
56
|
+
|
|
57
|
+
# Risk assessment
|
|
58
|
+
RISK_TIERS = %i[low medium high critical].freeze
|
|
59
|
+
RISK_RECOMMENDATIONS = {
|
|
60
|
+
low: :auto_approve,
|
|
61
|
+
medium: :governance,
|
|
62
|
+
high: :human_required,
|
|
63
|
+
critical: :blocked
|
|
64
|
+
}.freeze
|
|
51
65
|
end
|
|
52
66
|
end
|
|
53
67
|
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module MindGrowth
|
|
6
|
+
module Runners
|
|
7
|
+
module Governance
|
|
8
|
+
include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers) &&
|
|
9
|
+
Legion::Extensions::Helpers.const_defined?(:Lex)
|
|
10
|
+
|
|
11
|
+
extend self
|
|
12
|
+
|
|
13
|
+
VOTE_VALUES = %i[approve reject].freeze
|
|
14
|
+
|
|
15
|
+
def submit_proposal(proposal_id:, **)
|
|
16
|
+
proposal = Runners::Proposer.get_proposal_object(proposal_id)
|
|
17
|
+
return { success: false, error: :not_found } unless proposal
|
|
18
|
+
|
|
19
|
+
return { success: false, error: :invalid_status, current_status: proposal.status } unless %i[proposed evaluating].include?(proposal.status)
|
|
20
|
+
|
|
21
|
+
proposal.transition!(:evaluating)
|
|
22
|
+
{ success: true, proposal_id: proposal_id, status: :evaluating }
|
|
23
|
+
rescue ArgumentError => e
|
|
24
|
+
{ success: false, error: e.message }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def vote_on_proposal(proposal_id:, vote:, agent_id: 'default', rationale: nil, **)
|
|
28
|
+
vote_sym = vote.to_sym
|
|
29
|
+
return { success: false, error: :invalid_vote } unless VOTE_VALUES.include?(vote_sym)
|
|
30
|
+
|
|
31
|
+
votes_mutex.synchronize do
|
|
32
|
+
votes_store[proposal_id] ||= []
|
|
33
|
+
votes_store[proposal_id] << { vote: vote_sym, agent_id: agent_id.to_s, rationale: rationale,
|
|
34
|
+
cast_at: Time.now.utc }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
{ success: true, proposal_id: proposal_id, vote: vote_sym, agent_id: agent_id.to_s }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def tally_votes(proposal_id:, **)
|
|
41
|
+
ballots = votes_mutex.synchronize { (votes_store[proposal_id] || []).dup }
|
|
42
|
+
|
|
43
|
+
approve_count = ballots.count { |b| b[:vote] == :approve }
|
|
44
|
+
reject_count = ballots.count { |b| b[:vote] == :reject }
|
|
45
|
+
total = ballots.size
|
|
46
|
+
|
|
47
|
+
verdict = if total < Helpers::Constants::QUORUM
|
|
48
|
+
:pending
|
|
49
|
+
elsif approve_count > reject_count
|
|
50
|
+
:approved
|
|
51
|
+
else
|
|
52
|
+
:rejected
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
{ success: true, proposal_id: proposal_id, approve_count: approve_count,
|
|
56
|
+
reject_count: reject_count, total: total, verdict: verdict }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def approve_proposal(proposal_id:, _reason: nil, **)
|
|
60
|
+
proposal = Runners::Proposer.get_proposal_object(proposal_id)
|
|
61
|
+
return { success: false, error: :not_found } unless proposal
|
|
62
|
+
|
|
63
|
+
proposal.transition!(:approved)
|
|
64
|
+
{ success: true, proposal_id: proposal_id, status: :approved }
|
|
65
|
+
rescue ArgumentError => e
|
|
66
|
+
{ success: false, error: e.message }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def reject_proposal(proposal_id:, reason: nil, **)
|
|
70
|
+
proposal = Runners::Proposer.get_proposal_object(proposal_id)
|
|
71
|
+
return { success: false, error: :not_found } unless proposal
|
|
72
|
+
|
|
73
|
+
proposal.transition!(:rejected)
|
|
74
|
+
{ success: true, proposal_id: proposal_id, status: :rejected, reason: reason }
|
|
75
|
+
rescue ArgumentError => e
|
|
76
|
+
{ success: false, error: e.message }
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def governance_stats(**)
|
|
80
|
+
all_votes = votes_mutex.synchronize { votes_store.dup }
|
|
81
|
+
|
|
82
|
+
total_votes = all_votes.values.sum(&:size)
|
|
83
|
+
proposals_with_votes = all_votes.size
|
|
84
|
+
|
|
85
|
+
vote_summary = all_votes.transform_values do |ballots|
|
|
86
|
+
{
|
|
87
|
+
approve: ballots.count { |b| b[:vote] == :approve },
|
|
88
|
+
reject: ballots.count { |b| b[:vote] == :reject },
|
|
89
|
+
total: ballots.size
|
|
90
|
+
}
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
proposal_stats = Runners::Proposer.proposal_stats
|
|
94
|
+
by_status = proposal_stats[:stats][:by_status]
|
|
95
|
+
|
|
96
|
+
governance_breakdown = Helpers::Constants::GOVERNANCE_STATUSES.to_h do |s|
|
|
97
|
+
[s, by_status[s] || 0]
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
{
|
|
101
|
+
success: true,
|
|
102
|
+
total_votes: total_votes,
|
|
103
|
+
proposals_with_votes: proposals_with_votes,
|
|
104
|
+
vote_summary: vote_summary,
|
|
105
|
+
governance_breakdown: governance_breakdown
|
|
106
|
+
}
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
def votes_store
|
|
112
|
+
@votes_store ||= {}
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def votes_mutex
|
|
116
|
+
@votes_mutex ||= Mutex.new
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module MindGrowth
|
|
6
|
+
module Runners
|
|
7
|
+
module RiskAssessor
|
|
8
|
+
include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers) &&
|
|
9
|
+
Legion::Extensions::Helpers.const_defined?(:Lex)
|
|
10
|
+
|
|
11
|
+
extend self
|
|
12
|
+
|
|
13
|
+
HIGH_BLAST_CATEGORIES = %i[safety coordination].freeze
|
|
14
|
+
MEDIUM_BLAST_CATEGORIES = %i[cognition].freeze
|
|
15
|
+
HOT_PATH_CATEGORIES = %i[perception memory].freeze
|
|
16
|
+
|
|
17
|
+
def assess_risk(proposal_id:, **)
|
|
18
|
+
proposal = Runners::Proposer.get_proposal_object(proposal_id)
|
|
19
|
+
return { success: false, error: :not_found } unless proposal
|
|
20
|
+
|
|
21
|
+
dimensions = evaluate_dimensions(proposal)
|
|
22
|
+
tier = calculate_tier(dimensions)
|
|
23
|
+
recommendation = Helpers::Constants::RISK_RECOMMENDATIONS[tier]
|
|
24
|
+
|
|
25
|
+
{ success: true, proposal_id: proposal_id, risk_tier: tier,
|
|
26
|
+
dimensions: dimensions, recommendation: recommendation }
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def risk_summary(proposals: nil, **)
|
|
30
|
+
ids = if proposals
|
|
31
|
+
Array(proposals).map { |p| p.is_a?(Hash) ? p[:id] : p.to_s }
|
|
32
|
+
else
|
|
33
|
+
Runners::Proposer.list_proposals(limit: 100)[:proposals].map { |p| p[:id] }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
results = ids.filter_map do |id|
|
|
37
|
+
result = assess_risk(proposal_id: id)
|
|
38
|
+
next unless result[:success]
|
|
39
|
+
|
|
40
|
+
result
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
grouped = Helpers::Constants::RISK_TIERS.to_h { |tier| [tier, []] }
|
|
44
|
+
results.each { |r| grouped[r[:risk_tier]] << r }
|
|
45
|
+
|
|
46
|
+
{ success: true, total: results.size, by_tier: grouped }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def evaluate_dimensions(proposal)
|
|
52
|
+
helper_count = Array(proposal.helpers).size
|
|
53
|
+
category = proposal.category.to_sym
|
|
54
|
+
|
|
55
|
+
{
|
|
56
|
+
complexity: complexity_level(helper_count, Array(proposal.runner_methods).size),
|
|
57
|
+
blast_radius: blast_radius_level(category),
|
|
58
|
+
reversibility: :high,
|
|
59
|
+
performance_impact: performance_impact_level(category)
|
|
60
|
+
}
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def complexity_level(helper_count, runner_count)
|
|
64
|
+
total = helper_count + runner_count
|
|
65
|
+
if total >= 7
|
|
66
|
+
:high
|
|
67
|
+
elsif total >= 4
|
|
68
|
+
:medium
|
|
69
|
+
else
|
|
70
|
+
:low
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def blast_radius_level(category)
|
|
75
|
+
if HIGH_BLAST_CATEGORIES.include?(category)
|
|
76
|
+
:high
|
|
77
|
+
elsif MEDIUM_BLAST_CATEGORIES.include?(category)
|
|
78
|
+
:medium
|
|
79
|
+
else
|
|
80
|
+
:low
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def performance_impact_level(category)
|
|
85
|
+
HOT_PATH_CATEGORIES.include?(category) ? :medium : :low
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def calculate_tier(dimensions)
|
|
89
|
+
# Reversibility is a positive attribute (high = easily reversed) — exclude from risk calc
|
|
90
|
+
risk_values = dimensions.except(:reversibility).values
|
|
91
|
+
|
|
92
|
+
if risk_values.include?(:critical)
|
|
93
|
+
:critical
|
|
94
|
+
elsif risk_values.include?(:high)
|
|
95
|
+
:high
|
|
96
|
+
elsif risk_values.include?(:medium)
|
|
97
|
+
:medium
|
|
98
|
+
else
|
|
99
|
+
:low
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
@@ -18,6 +18,8 @@ require 'legion/extensions/mind_growth/runners/orchestrator'
|
|
|
18
18
|
require 'legion/extensions/mind_growth/runners/wirer'
|
|
19
19
|
require 'legion/extensions/mind_growth/runners/integration_tester'
|
|
20
20
|
require 'legion/extensions/mind_growth/runners/retrospective'
|
|
21
|
+
require 'legion/extensions/mind_growth/runners/governance'
|
|
22
|
+
require 'legion/extensions/mind_growth/runners/risk_assessor'
|
|
21
23
|
require 'legion/extensions/mind_growth/client'
|
|
22
24
|
|
|
23
25
|
module Legion
|
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe Legion::Extensions::MindGrowth::Runners::Governance do
|
|
4
|
+
subject(:governance) { described_class }
|
|
5
|
+
|
|
6
|
+
let(:proposer) { Legion::Extensions::MindGrowth::Runners::Proposer }
|
|
7
|
+
|
|
8
|
+
before do
|
|
9
|
+
proposer.instance_variable_set(:@proposal_store, nil)
|
|
10
|
+
governance.instance_variable_set(:@votes_store, nil)
|
|
11
|
+
governance.instance_variable_set(:@votes_mutex, nil)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def create_proposal(name: 'lex-gov-test', category: :cognition, status: nil)
|
|
15
|
+
result = proposer.propose_concept(name: name, category: category, description: 'test proposal', enrich: false)
|
|
16
|
+
proposal = proposer.get_proposal_object(result[:proposal][:id])
|
|
17
|
+
proposal.transition!(status) if status && status != :proposed
|
|
18
|
+
proposal
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# ─── submit_proposal ──────────────────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
describe '.submit_proposal' do
|
|
24
|
+
context 'with a valid proposed proposal' do
|
|
25
|
+
it 'returns success: true' do
|
|
26
|
+
proposal = create_proposal
|
|
27
|
+
result = governance.submit_proposal(proposal_id: proposal.id)
|
|
28
|
+
expect(result[:success]).to be true
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
it 'returns the proposal_id' do
|
|
32
|
+
proposal = create_proposal
|
|
33
|
+
result = governance.submit_proposal(proposal_id: proposal.id)
|
|
34
|
+
expect(result[:proposal_id]).to eq(proposal.id)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
it 'returns status: :evaluating' do
|
|
38
|
+
proposal = create_proposal
|
|
39
|
+
result = governance.submit_proposal(proposal_id: proposal.id)
|
|
40
|
+
expect(result[:status]).to eq(:evaluating)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
it 'transitions the proposal to :evaluating' do
|
|
44
|
+
proposal = create_proposal
|
|
45
|
+
governance.submit_proposal(proposal_id: proposal.id)
|
|
46
|
+
expect(proposal.status).to eq(:evaluating)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
context 'with an already-evaluating proposal' do
|
|
51
|
+
it 'returns success: true and keeps :evaluating status' do
|
|
52
|
+
proposal = create_proposal
|
|
53
|
+
governance.submit_proposal(proposal_id: proposal.id)
|
|
54
|
+
result = governance.submit_proposal(proposal_id: proposal.id)
|
|
55
|
+
expect(result[:success]).to be true
|
|
56
|
+
expect(result[:status]).to eq(:evaluating)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
context 'with a proposal in an invalid status' do
|
|
61
|
+
it 'returns success: false for :building status' do
|
|
62
|
+
proposal = create_proposal(status: :building)
|
|
63
|
+
result = governance.submit_proposal(proposal_id: proposal.id)
|
|
64
|
+
expect(result[:success]).to be false
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
it 'returns :invalid_status error' do
|
|
68
|
+
proposal = create_proposal(status: :building)
|
|
69
|
+
result = governance.submit_proposal(proposal_id: proposal.id)
|
|
70
|
+
expect(result[:error]).to eq(:invalid_status)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
it 'includes current_status in the error response' do
|
|
74
|
+
proposal = create_proposal(status: :building)
|
|
75
|
+
result = governance.submit_proposal(proposal_id: proposal.id)
|
|
76
|
+
expect(result[:current_status]).to eq(:building)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
context 'with a non-existent proposal_id' do
|
|
81
|
+
it 'returns success: false' do
|
|
82
|
+
result = governance.submit_proposal(proposal_id: 'no-such-id')
|
|
83
|
+
expect(result[:success]).to be false
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
it 'returns :not_found error' do
|
|
87
|
+
result = governance.submit_proposal(proposal_id: 'no-such-id')
|
|
88
|
+
expect(result[:error]).to eq(:not_found)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
it 'ignores unknown keyword arguments' do
|
|
93
|
+
proposal = create_proposal
|
|
94
|
+
expect { governance.submit_proposal(proposal_id: proposal.id, extra: true) }.not_to raise_error
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# ─── vote_on_proposal ─────────────────────────────────────────────────────
|
|
99
|
+
|
|
100
|
+
describe '.vote_on_proposal' do
|
|
101
|
+
let(:proposal) { create_proposal }
|
|
102
|
+
|
|
103
|
+
it 'returns success: true for :approve vote' do
|
|
104
|
+
result = governance.vote_on_proposal(proposal_id: proposal.id, vote: :approve)
|
|
105
|
+
expect(result[:success]).to be true
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
it 'returns success: true for :reject vote' do
|
|
109
|
+
result = governance.vote_on_proposal(proposal_id: proposal.id, vote: :reject)
|
|
110
|
+
expect(result[:success]).to be true
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
it 'returns the proposal_id' do
|
|
114
|
+
result = governance.vote_on_proposal(proposal_id: proposal.id, vote: :approve)
|
|
115
|
+
expect(result[:proposal_id]).to eq(proposal.id)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
it 'returns the vote symbol' do
|
|
119
|
+
result = governance.vote_on_proposal(proposal_id: proposal.id, vote: :approve)
|
|
120
|
+
expect(result[:vote]).to eq(:approve)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
it 'returns the agent_id' do
|
|
124
|
+
result = governance.vote_on_proposal(proposal_id: proposal.id, vote: :approve, agent_id: 'agent-1')
|
|
125
|
+
expect(result[:agent_id]).to eq('agent-1')
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
it 'uses "default" as agent_id when not provided' do
|
|
129
|
+
result = governance.vote_on_proposal(proposal_id: proposal.id, vote: :approve)
|
|
130
|
+
expect(result[:agent_id]).to eq('default')
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
it 'accepts string vote values and coerces to symbol' do
|
|
134
|
+
result = governance.vote_on_proposal(proposal_id: proposal.id, vote: 'approve')
|
|
135
|
+
expect(result[:success]).to be true
|
|
136
|
+
expect(result[:vote]).to eq(:approve)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
it 'returns success: false for invalid vote' do
|
|
140
|
+
result = governance.vote_on_proposal(proposal_id: proposal.id, vote: :maybe)
|
|
141
|
+
expect(result[:success]).to be false
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
it 'returns :invalid_vote error for unknown vote' do
|
|
145
|
+
result = governance.vote_on_proposal(proposal_id: proposal.id, vote: :abstain)
|
|
146
|
+
expect(result[:error]).to eq(:invalid_vote)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
it 'accumulates multiple votes for the same proposal' do
|
|
150
|
+
3.times { |i| governance.vote_on_proposal(proposal_id: proposal.id, vote: :approve, agent_id: "agent-#{i}") }
|
|
151
|
+
tally = governance.tally_votes(proposal_id: proposal.id)
|
|
152
|
+
expect(tally[:total]).to eq(3)
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
context 'thread safety' do
|
|
156
|
+
it 'records all votes when cast concurrently' do
|
|
157
|
+
threads = 10.times.map do |i|
|
|
158
|
+
Thread.new { governance.vote_on_proposal(proposal_id: proposal.id, vote: :approve, agent_id: "t#{i}") }
|
|
159
|
+
end
|
|
160
|
+
threads.each(&:join)
|
|
161
|
+
tally = governance.tally_votes(proposal_id: proposal.id)
|
|
162
|
+
expect(tally[:total]).to eq(10)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
it 'correctly tallies mixed concurrent votes' do
|
|
166
|
+
threads = []
|
|
167
|
+
5.times { |i| threads << Thread.new { governance.vote_on_proposal(proposal_id: proposal.id, vote: :approve, agent_id: "a#{i}") } }
|
|
168
|
+
5.times { |i| threads << Thread.new { governance.vote_on_proposal(proposal_id: proposal.id, vote: :reject, agent_id: "r#{i}") } }
|
|
169
|
+
threads.each(&:join)
|
|
170
|
+
tally = governance.tally_votes(proposal_id: proposal.id)
|
|
171
|
+
expect(tally[:approve_count]).to eq(5)
|
|
172
|
+
expect(tally[:reject_count]).to eq(5)
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# ─── tally_votes ──────────────────────────────────────────────────────────
|
|
178
|
+
|
|
179
|
+
describe '.tally_votes' do
|
|
180
|
+
let(:proposal) { create_proposal }
|
|
181
|
+
|
|
182
|
+
it 'returns success: true' do
|
|
183
|
+
result = governance.tally_votes(proposal_id: proposal.id)
|
|
184
|
+
expect(result[:success]).to be true
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
it 'returns the proposal_id' do
|
|
188
|
+
result = governance.tally_votes(proposal_id: proposal.id)
|
|
189
|
+
expect(result[:proposal_id]).to eq(proposal.id)
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
it 'returns zero counts with no votes' do
|
|
193
|
+
result = governance.tally_votes(proposal_id: proposal.id)
|
|
194
|
+
expect(result[:approve_count]).to eq(0)
|
|
195
|
+
expect(result[:reject_count]).to eq(0)
|
|
196
|
+
expect(result[:total]).to eq(0)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
it 'returns :pending verdict when total < QUORUM' do
|
|
200
|
+
2.times { |i| governance.vote_on_proposal(proposal_id: proposal.id, vote: :approve, agent_id: "a#{i}") }
|
|
201
|
+
result = governance.tally_votes(proposal_id: proposal.id)
|
|
202
|
+
expect(result[:verdict]).to eq(:pending)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
it 'returns :approved verdict when approve_count > reject_count and total >= QUORUM' do
|
|
206
|
+
3.times { |i| governance.vote_on_proposal(proposal_id: proposal.id, vote: :approve, agent_id: "a#{i}") }
|
|
207
|
+
result = governance.tally_votes(proposal_id: proposal.id)
|
|
208
|
+
expect(result[:verdict]).to eq(:approved)
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
it 'returns :rejected verdict when reject_count >= approve_count and total >= QUORUM' do
|
|
212
|
+
2.times { |i| governance.vote_on_proposal(proposal_id: proposal.id, vote: :reject, agent_id: "r#{i}") }
|
|
213
|
+
governance.vote_on_proposal(proposal_id: proposal.id, vote: :approve, agent_id: 'a0')
|
|
214
|
+
result = governance.tally_votes(proposal_id: proposal.id)
|
|
215
|
+
expect(result[:verdict]).to eq(:rejected)
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
it 'returns :rejected on a tie when total >= QUORUM' do
|
|
219
|
+
3.times do |i|
|
|
220
|
+
governance.vote_on_proposal(proposal_id: proposal.id, vote: :approve, agent_id: "a#{i}")
|
|
221
|
+
governance.vote_on_proposal(proposal_id: proposal.id, vote: :reject, agent_id: "r#{i}")
|
|
222
|
+
end
|
|
223
|
+
result = governance.tally_votes(proposal_id: proposal.id)
|
|
224
|
+
expect(result[:verdict]).to eq(:rejected)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
it 'counts approve votes correctly' do
|
|
228
|
+
2.times { |i| governance.vote_on_proposal(proposal_id: proposal.id, vote: :approve, agent_id: "a#{i}") }
|
|
229
|
+
governance.vote_on_proposal(proposal_id: proposal.id, vote: :reject, agent_id: 'r0')
|
|
230
|
+
result = governance.tally_votes(proposal_id: proposal.id)
|
|
231
|
+
expect(result[:approve_count]).to eq(2)
|
|
232
|
+
expect(result[:reject_count]).to eq(1)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
it 'returns :pending for an unknown proposal_id with no votes' do
|
|
236
|
+
result = governance.tally_votes(proposal_id: 'nonexistent')
|
|
237
|
+
expect(result[:verdict]).to eq(:pending)
|
|
238
|
+
expect(result[:total]).to eq(0)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
it 'requires exactly QUORUM votes for non-pending verdict' do
|
|
242
|
+
quorum = Legion::Extensions::MindGrowth::Helpers::Constants::QUORUM
|
|
243
|
+
(quorum - 1).times { |i| governance.vote_on_proposal(proposal_id: proposal.id, vote: :approve, agent_id: "a#{i}") }
|
|
244
|
+
result = governance.tally_votes(proposal_id: proposal.id)
|
|
245
|
+
expect(result[:verdict]).to eq(:pending)
|
|
246
|
+
|
|
247
|
+
governance.vote_on_proposal(proposal_id: proposal.id, vote: :approve, agent_id: 'final')
|
|
248
|
+
result = governance.tally_votes(proposal_id: proposal.id)
|
|
249
|
+
expect(result[:verdict]).to eq(:approved)
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# ─── approve_proposal ─────────────────────────────────────────────────────
|
|
254
|
+
|
|
255
|
+
describe '.approve_proposal' do
|
|
256
|
+
it 'returns success: true' do
|
|
257
|
+
proposal = create_proposal
|
|
258
|
+
result = governance.approve_proposal(proposal_id: proposal.id)
|
|
259
|
+
expect(result[:success]).to be true
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
it 'returns status: :approved' do
|
|
263
|
+
proposal = create_proposal
|
|
264
|
+
result = governance.approve_proposal(proposal_id: proposal.id)
|
|
265
|
+
expect(result[:status]).to eq(:approved)
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
it 'transitions the proposal to :approved' do
|
|
269
|
+
proposal = create_proposal
|
|
270
|
+
governance.approve_proposal(proposal_id: proposal.id)
|
|
271
|
+
expect(proposal.status).to eq(:approved)
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
it 'returns the proposal_id' do
|
|
275
|
+
proposal = create_proposal
|
|
276
|
+
result = governance.approve_proposal(proposal_id: proposal.id)
|
|
277
|
+
expect(result[:proposal_id]).to eq(proposal.id)
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
it 'returns success: false for non-existent proposal' do
|
|
281
|
+
result = governance.approve_proposal(proposal_id: 'missing')
|
|
282
|
+
expect(result[:success]).to be false
|
|
283
|
+
expect(result[:error]).to eq(:not_found)
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
it 'accepts an optional reason keyword' do
|
|
287
|
+
proposal = create_proposal
|
|
288
|
+
expect { governance.approve_proposal(proposal_id: proposal.id, reason: 'looks good') }.not_to raise_error
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# ─── reject_proposal ──────────────────────────────────────────────────────
|
|
293
|
+
|
|
294
|
+
describe '.reject_proposal' do
|
|
295
|
+
it 'returns success: true' do
|
|
296
|
+
proposal = create_proposal
|
|
297
|
+
result = governance.reject_proposal(proposal_id: proposal.id)
|
|
298
|
+
expect(result[:success]).to be true
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
it 'returns status: :rejected' do
|
|
302
|
+
proposal = create_proposal
|
|
303
|
+
result = governance.reject_proposal(proposal_id: proposal.id)
|
|
304
|
+
expect(result[:status]).to eq(:rejected)
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
it 'transitions the proposal to :rejected' do
|
|
308
|
+
proposal = create_proposal
|
|
309
|
+
governance.reject_proposal(proposal_id: proposal.id)
|
|
310
|
+
expect(proposal.status).to eq(:rejected)
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
it 'returns the proposal_id' do
|
|
314
|
+
proposal = create_proposal
|
|
315
|
+
result = governance.reject_proposal(proposal_id: proposal.id)
|
|
316
|
+
expect(result[:proposal_id]).to eq(proposal.id)
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
it 'includes the reason in the response' do
|
|
320
|
+
proposal = create_proposal
|
|
321
|
+
result = governance.reject_proposal(proposal_id: proposal.id, reason: 'too risky')
|
|
322
|
+
expect(result[:reason]).to eq('too risky')
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
it 'returns nil reason when none provided' do
|
|
326
|
+
proposal = create_proposal
|
|
327
|
+
result = governance.reject_proposal(proposal_id: proposal.id)
|
|
328
|
+
expect(result[:reason]).to be_nil
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
it 'returns success: false for non-existent proposal' do
|
|
332
|
+
result = governance.reject_proposal(proposal_id: 'missing')
|
|
333
|
+
expect(result[:success]).to be false
|
|
334
|
+
expect(result[:error]).to eq(:not_found)
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
|
|
338
|
+
# ─── governance_stats ─────────────────────────────────────────────────────
|
|
339
|
+
|
|
340
|
+
describe '.governance_stats' do
|
|
341
|
+
it 'returns success: true' do
|
|
342
|
+
result = governance.governance_stats
|
|
343
|
+
expect(result[:success]).to be true
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
it 'includes total_votes key' do
|
|
347
|
+
result = governance.governance_stats
|
|
348
|
+
expect(result).to have_key(:total_votes)
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
it 'includes proposals_with_votes key' do
|
|
352
|
+
result = governance.governance_stats
|
|
353
|
+
expect(result).to have_key(:proposals_with_votes)
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
it 'includes vote_summary key' do
|
|
357
|
+
result = governance.governance_stats
|
|
358
|
+
expect(result).to have_key(:vote_summary)
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
it 'includes governance_breakdown key' do
|
|
362
|
+
result = governance.governance_stats
|
|
363
|
+
expect(result).to have_key(:governance_breakdown)
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
it 'reports zero total_votes when no votes cast' do
|
|
367
|
+
result = governance.governance_stats
|
|
368
|
+
expect(result[:total_votes]).to eq(0)
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
it 'reports zero proposals_with_votes when no votes cast' do
|
|
372
|
+
result = governance.governance_stats
|
|
373
|
+
expect(result[:proposals_with_votes]).to eq(0)
|
|
374
|
+
end
|
|
375
|
+
|
|
376
|
+
it 'governance_breakdown includes all GOVERNANCE_STATUSES' do
|
|
377
|
+
result = governance.governance_stats
|
|
378
|
+
Legion::Extensions::MindGrowth::Helpers::Constants::GOVERNANCE_STATUSES.each do |status|
|
|
379
|
+
expect(result[:governance_breakdown]).to have_key(status)
|
|
380
|
+
end
|
|
381
|
+
end
|
|
382
|
+
|
|
383
|
+
context 'after voting' do
|
|
384
|
+
let(:proposal) { create_proposal }
|
|
385
|
+
|
|
386
|
+
before do
|
|
387
|
+
3.times { |i| governance.vote_on_proposal(proposal_id: proposal.id, vote: :approve, agent_id: "a#{i}") }
|
|
388
|
+
governance.vote_on_proposal(proposal_id: proposal.id, vote: :reject, agent_id: 'r0')
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
it 'reflects total_votes correctly' do
|
|
392
|
+
result = governance.governance_stats
|
|
393
|
+
expect(result[:total_votes]).to eq(4)
|
|
394
|
+
end
|
|
395
|
+
|
|
396
|
+
it 'reflects proposals_with_votes correctly' do
|
|
397
|
+
result = governance.governance_stats
|
|
398
|
+
expect(result[:proposals_with_votes]).to eq(1)
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
it 'vote_summary for the proposal has correct approve/reject counts' do
|
|
402
|
+
result = governance.governance_stats
|
|
403
|
+
summary = result[:vote_summary][proposal.id]
|
|
404
|
+
expect(summary[:approve]).to eq(3)
|
|
405
|
+
expect(summary[:reject]).to eq(1)
|
|
406
|
+
expect(summary[:total]).to eq(4)
|
|
407
|
+
end
|
|
408
|
+
end
|
|
409
|
+
|
|
410
|
+
context 'with multiple proposals' do
|
|
411
|
+
it 'counts votes across proposals correctly' do
|
|
412
|
+
p1 = create_proposal(name: 'lex-g1')
|
|
413
|
+
p2 = create_proposal(name: 'lex-g2')
|
|
414
|
+
2.times { |i| governance.vote_on_proposal(proposal_id: p1.id, vote: :approve, agent_id: "a#{i}") }
|
|
415
|
+
3.times { |i| governance.vote_on_proposal(proposal_id: p2.id, vote: :reject, agent_id: "r#{i}") }
|
|
416
|
+
result = governance.governance_stats
|
|
417
|
+
expect(result[:total_votes]).to eq(5)
|
|
418
|
+
expect(result[:proposals_with_votes]).to eq(2)
|
|
419
|
+
end
|
|
420
|
+
end
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
# ─── constant checks ──────────────────────────────────────────────────────
|
|
424
|
+
|
|
425
|
+
describe 'constants' do
|
|
426
|
+
it 'QUORUM is 3' do
|
|
427
|
+
expect(Legion::Extensions::MindGrowth::Helpers::Constants::QUORUM).to eq(3)
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
it 'REJECTION_COOLDOWN_HOURS is 24' do
|
|
431
|
+
expect(Legion::Extensions::MindGrowth::Helpers::Constants::REJECTION_COOLDOWN_HOURS).to eq(24)
|
|
432
|
+
end
|
|
433
|
+
|
|
434
|
+
it 'GOVERNANCE_STATUSES includes :pending, :approved, :rejected, :expired' do
|
|
435
|
+
statuses = Legion::Extensions::MindGrowth::Helpers::Constants::GOVERNANCE_STATUSES
|
|
436
|
+
expect(statuses).to include(:pending, :approved, :rejected, :expired)
|
|
437
|
+
end
|
|
438
|
+
end
|
|
439
|
+
end
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe Legion::Extensions::MindGrowth::Runners::RiskAssessor do
|
|
4
|
+
subject(:risk_assessor) { described_class }
|
|
5
|
+
|
|
6
|
+
let(:proposer) { Legion::Extensions::MindGrowth::Runners::Proposer }
|
|
7
|
+
|
|
8
|
+
before { proposer.instance_variable_set(:@proposal_store, nil) }
|
|
9
|
+
|
|
10
|
+
def create_proposal(name: 'lex-risk-test', category: :cognition, helpers: [], runner_methods: [])
|
|
11
|
+
result = proposer.propose_concept(name: name, category: category, description: 'risk test', enrich: false)
|
|
12
|
+
proposal = proposer.get_proposal_object(result[:proposal][:id])
|
|
13
|
+
# Inject helpers and runner_methods via instance variables for test control
|
|
14
|
+
proposal.instance_variable_set(:@helpers, helpers)
|
|
15
|
+
proposal.instance_variable_set(:@runner_methods, runner_methods)
|
|
16
|
+
proposal
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# ─── assess_risk ──────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
describe '.assess_risk' do
|
|
22
|
+
context 'with a basic cognition proposal' do
|
|
23
|
+
let(:proposal) { create_proposal }
|
|
24
|
+
|
|
25
|
+
it 'returns success: true' do
|
|
26
|
+
result = risk_assessor.assess_risk(proposal_id: proposal.id)
|
|
27
|
+
expect(result[:success]).to be true
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
it 'returns the proposal_id' do
|
|
31
|
+
result = risk_assessor.assess_risk(proposal_id: proposal.id)
|
|
32
|
+
expect(result[:proposal_id]).to eq(proposal.id)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
it 'returns a risk_tier' do
|
|
36
|
+
result = risk_assessor.assess_risk(proposal_id: proposal.id)
|
|
37
|
+
expect(Legion::Extensions::MindGrowth::Helpers::Constants::RISK_TIERS).to include(result[:risk_tier])
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
it 'returns a dimensions hash' do
|
|
41
|
+
result = risk_assessor.assess_risk(proposal_id: proposal.id)
|
|
42
|
+
expect(result[:dimensions]).to be_a(Hash)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
it 'dimensions includes :complexity' do
|
|
46
|
+
result = risk_assessor.assess_risk(proposal_id: proposal.id)
|
|
47
|
+
expect(result[:dimensions]).to have_key(:complexity)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
it 'dimensions includes :blast_radius' do
|
|
51
|
+
result = risk_assessor.assess_risk(proposal_id: proposal.id)
|
|
52
|
+
expect(result[:dimensions]).to have_key(:blast_radius)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
it 'dimensions includes :reversibility' do
|
|
56
|
+
result = risk_assessor.assess_risk(proposal_id: proposal.id)
|
|
57
|
+
expect(result[:dimensions]).to have_key(:reversibility)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
it 'dimensions includes :performance_impact' do
|
|
61
|
+
result = risk_assessor.assess_risk(proposal_id: proposal.id)
|
|
62
|
+
expect(result[:dimensions]).to have_key(:performance_impact)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
it 'reversibility is always :high' do
|
|
66
|
+
result = risk_assessor.assess_risk(proposal_id: proposal.id)
|
|
67
|
+
expect(result[:dimensions][:reversibility]).to eq(:high)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
it 'returns a recommendation' do
|
|
71
|
+
result = risk_assessor.assess_risk(proposal_id: proposal.id)
|
|
72
|
+
expect(Legion::Extensions::MindGrowth::Helpers::Constants::RISK_RECOMMENDATIONS.values).to include(result[:recommendation])
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
context 'with a non-existent proposal_id' do
|
|
77
|
+
it 'returns success: false' do
|
|
78
|
+
result = risk_assessor.assess_risk(proposal_id: 'no-such-id')
|
|
79
|
+
expect(result[:success]).to be false
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
it 'returns :not_found error' do
|
|
83
|
+
result = risk_assessor.assess_risk(proposal_id: 'no-such-id')
|
|
84
|
+
expect(result[:error]).to eq(:not_found)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
it 'ignores unknown keyword arguments' do
|
|
89
|
+
proposal = create_proposal
|
|
90
|
+
expect { risk_assessor.assess_risk(proposal_id: proposal.id, extra: true) }.not_to raise_error
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# ── complexity dimension ──────────────────────────────────────────────
|
|
94
|
+
|
|
95
|
+
context 'complexity based on helpers + runner_methods count' do
|
|
96
|
+
it 'is :low when total < 4 (0 helpers, 0 runner_methods)' do
|
|
97
|
+
proposal = create_proposal(helpers: [], runner_methods: [])
|
|
98
|
+
result = risk_assessor.assess_risk(proposal_id: proposal.id)
|
|
99
|
+
expect(result[:dimensions][:complexity]).to eq(:low)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
it 'is :low when total is 3' do
|
|
103
|
+
proposal = create_proposal(helpers: [{ name: 'h1' }, { name: 'h2' }], runner_methods: [{ name: 'r1' }])
|
|
104
|
+
result = risk_assessor.assess_risk(proposal_id: proposal.id)
|
|
105
|
+
expect(result[:dimensions][:complexity]).to eq(:low)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
it 'is :medium when total is 4' do
|
|
109
|
+
helpers = Array.new(2) { |i| { name: "h#{i}" } }
|
|
110
|
+
runners = Array.new(2) { |i| { name: "r#{i}" } }
|
|
111
|
+
proposal = create_proposal(helpers: helpers, runner_methods: runners)
|
|
112
|
+
result = risk_assessor.assess_risk(proposal_id: proposal.id)
|
|
113
|
+
expect(result[:dimensions][:complexity]).to eq(:medium)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
it 'is :medium when total is 6' do
|
|
117
|
+
helpers = Array.new(3) { |i| { name: "h#{i}" } }
|
|
118
|
+
runners = Array.new(3) { |i| { name: "r#{i}" } }
|
|
119
|
+
proposal = create_proposal(helpers: helpers, runner_methods: runners)
|
|
120
|
+
result = risk_assessor.assess_risk(proposal_id: proposal.id)
|
|
121
|
+
expect(result[:dimensions][:complexity]).to eq(:medium)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
it 'is :high when total is 7' do
|
|
125
|
+
helpers = Array.new(4) { |i| { name: "h#{i}" } }
|
|
126
|
+
runners = Array.new(3) { |i| { name: "r#{i}" } }
|
|
127
|
+
proposal = create_proposal(helpers: helpers, runner_methods: runners)
|
|
128
|
+
result = risk_assessor.assess_risk(proposal_id: proposal.id)
|
|
129
|
+
expect(result[:dimensions][:complexity]).to eq(:high)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
it 'is :high when total > 7' do
|
|
133
|
+
helpers = Array.new(5) { |i| { name: "h#{i}" } }
|
|
134
|
+
runners = Array.new(5) { |i| { name: "r#{i}" } }
|
|
135
|
+
proposal = create_proposal(helpers: helpers, runner_methods: runners)
|
|
136
|
+
result = risk_assessor.assess_risk(proposal_id: proposal.id)
|
|
137
|
+
expect(result[:dimensions][:complexity]).to eq(:high)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# ── blast_radius dimension ────────────────────────────────────────────
|
|
142
|
+
|
|
143
|
+
context 'blast_radius based on category' do
|
|
144
|
+
it 'is :high for :safety category' do
|
|
145
|
+
proposal = create_proposal(category: :safety)
|
|
146
|
+
result = risk_assessor.assess_risk(proposal_id: proposal.id)
|
|
147
|
+
expect(result[:dimensions][:blast_radius]).to eq(:high)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
it 'is :high for :coordination category' do
|
|
151
|
+
proposal = create_proposal(name: 'lex-coord', category: :coordination)
|
|
152
|
+
result = risk_assessor.assess_risk(proposal_id: proposal.id)
|
|
153
|
+
expect(result[:dimensions][:blast_radius]).to eq(:high)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
it 'is :medium for :cognition category' do
|
|
157
|
+
proposal = create_proposal(category: :cognition)
|
|
158
|
+
result = risk_assessor.assess_risk(proposal_id: proposal.id)
|
|
159
|
+
expect(result[:dimensions][:blast_radius]).to eq(:medium)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
it 'is :low for :communication category' do
|
|
163
|
+
proposal = create_proposal(name: 'lex-comm', category: :communication)
|
|
164
|
+
result = risk_assessor.assess_risk(proposal_id: proposal.id)
|
|
165
|
+
expect(result[:dimensions][:blast_radius]).to eq(:low)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
it 'is :low for :motivation category' do
|
|
169
|
+
proposal = create_proposal(name: 'lex-motiv', category: :motivation)
|
|
170
|
+
result = risk_assessor.assess_risk(proposal_id: proposal.id)
|
|
171
|
+
expect(result[:dimensions][:blast_radius]).to eq(:low)
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
it 'is :low for :introspection category' do
|
|
175
|
+
proposal = create_proposal(name: 'lex-intro', category: :introspection)
|
|
176
|
+
result = risk_assessor.assess_risk(proposal_id: proposal.id)
|
|
177
|
+
expect(result[:dimensions][:blast_radius]).to eq(:low)
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# ── performance_impact dimension ──────────────────────────────────────
|
|
182
|
+
|
|
183
|
+
context 'performance_impact based on category' do
|
|
184
|
+
it 'is :medium for :perception category (hot path)' do
|
|
185
|
+
proposal = create_proposal(name: 'lex-perc', category: :perception)
|
|
186
|
+
result = risk_assessor.assess_risk(proposal_id: proposal.id)
|
|
187
|
+
expect(result[:dimensions][:performance_impact]).to eq(:medium)
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
it 'is :medium for :memory category (hot path)' do
|
|
191
|
+
proposal = create_proposal(name: 'lex-mem', category: :memory)
|
|
192
|
+
result = risk_assessor.assess_risk(proposal_id: proposal.id)
|
|
193
|
+
expect(result[:dimensions][:performance_impact]).to eq(:medium)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
it 'is :low for :cognition category' do
|
|
197
|
+
proposal = create_proposal(category: :cognition)
|
|
198
|
+
result = risk_assessor.assess_risk(proposal_id: proposal.id)
|
|
199
|
+
expect(result[:dimensions][:performance_impact]).to eq(:low)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
it 'is :low for :safety category' do
|
|
203
|
+
proposal = create_proposal(category: :safety)
|
|
204
|
+
result = risk_assessor.assess_risk(proposal_id: proposal.id)
|
|
205
|
+
expect(result[:dimensions][:performance_impact]).to eq(:low)
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
# ── risk_tier calculation ─────────────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
context 'risk tier calculation' do
|
|
212
|
+
it 'is :low when all dimensions are low (communication, few helpers)' do
|
|
213
|
+
proposal = create_proposal(name: 'lex-low', category: :communication,
|
|
214
|
+
helpers: [], runner_methods: [])
|
|
215
|
+
result = risk_assessor.assess_risk(proposal_id: proposal.id)
|
|
216
|
+
expect(result[:risk_tier]).to eq(:low)
|
|
217
|
+
expect(result[:recommendation]).to eq(:auto_approve)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
it 'is :medium when any dimension is medium (cognition with low complexity)' do
|
|
221
|
+
proposal = create_proposal(category: :cognition, helpers: [], runner_methods: [])
|
|
222
|
+
result = risk_assessor.assess_risk(proposal_id: proposal.id)
|
|
223
|
+
# cognition => blast_radius :medium
|
|
224
|
+
expect(result[:risk_tier]).to eq(:medium)
|
|
225
|
+
expect(result[:recommendation]).to eq(:governance)
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
it 'is :medium when perception category with low complexity' do
|
|
229
|
+
proposal = create_proposal(name: 'lex-perc2', category: :perception,
|
|
230
|
+
helpers: [], runner_methods: [])
|
|
231
|
+
result = risk_assessor.assess_risk(proposal_id: proposal.id)
|
|
232
|
+
# perception => performance_impact :medium
|
|
233
|
+
expect(result[:risk_tier]).to eq(:medium)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
it 'is :high when blast_radius is :high (safety category)' do
|
|
237
|
+
proposal = create_proposal(category: :safety)
|
|
238
|
+
result = risk_assessor.assess_risk(proposal_id: proposal.id)
|
|
239
|
+
expect(result[:risk_tier]).to eq(:high)
|
|
240
|
+
expect(result[:recommendation]).to eq(:human_required)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
it 'is :high when blast_radius is :high (coordination category)' do
|
|
244
|
+
proposal = create_proposal(name: 'lex-coord2', category: :coordination)
|
|
245
|
+
result = risk_assessor.assess_risk(proposal_id: proposal.id)
|
|
246
|
+
expect(result[:risk_tier]).to eq(:high)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
it 'is :high when complexity is :high (7+ helpers+runners)' do
|
|
250
|
+
helpers = Array.new(4) { |i| { name: "h#{i}" } }
|
|
251
|
+
runners = Array.new(4) { |i| { name: "r#{i}" } }
|
|
252
|
+
proposal = create_proposal(name: 'lex-complex', category: :communication,
|
|
253
|
+
helpers: helpers, runner_methods: runners)
|
|
254
|
+
result = risk_assessor.assess_risk(proposal_id: proposal.id)
|
|
255
|
+
expect(result[:risk_tier]).to eq(:high)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
it 'is :high (not :medium) when both blast_radius and complexity are high' do
|
|
259
|
+
helpers = Array.new(4) { |i| { name: "h#{i}" } }
|
|
260
|
+
runners = Array.new(4) { |i| { name: "r#{i}" } }
|
|
261
|
+
proposal = create_proposal(name: 'lex-worst', category: :safety,
|
|
262
|
+
helpers: helpers, runner_methods: runners)
|
|
263
|
+
result = risk_assessor.assess_risk(proposal_id: proposal.id)
|
|
264
|
+
expect(result[:risk_tier]).to eq(:high)
|
|
265
|
+
end
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# ── recommendation mapping ────────────────────────────────────────────
|
|
269
|
+
|
|
270
|
+
context 'recommendation mapping' do
|
|
271
|
+
it 'maps :low tier to :auto_approve' do
|
|
272
|
+
proposal = create_proposal(name: 'lex-auto', category: :communication,
|
|
273
|
+
helpers: [], runner_methods: [])
|
|
274
|
+
result = risk_assessor.assess_risk(proposal_id: proposal.id)
|
|
275
|
+
expect(result[:recommendation]).to eq(:auto_approve)
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
it 'maps :medium tier to :governance' do
|
|
279
|
+
proposal = create_proposal(category: :cognition)
|
|
280
|
+
result = risk_assessor.assess_risk(proposal_id: proposal.id)
|
|
281
|
+
expect(result[:recommendation]).to eq(:governance)
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
it 'maps :high tier to :human_required' do
|
|
285
|
+
proposal = create_proposal(category: :safety)
|
|
286
|
+
result = risk_assessor.assess_risk(proposal_id: proposal.id)
|
|
287
|
+
expect(result[:recommendation]).to eq(:human_required)
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
# ─── risk_summary ─────────────────────────────────────────────────────────
|
|
293
|
+
|
|
294
|
+
describe '.risk_summary' do
|
|
295
|
+
before { proposer.instance_variable_set(:@proposal_store, nil) }
|
|
296
|
+
|
|
297
|
+
it 'returns success: true' do
|
|
298
|
+
result = risk_assessor.risk_summary
|
|
299
|
+
expect(result[:success]).to be true
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
it 'includes total count' do
|
|
303
|
+
result = risk_assessor.risk_summary
|
|
304
|
+
expect(result).to have_key(:total)
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
it 'includes by_tier hash' do
|
|
308
|
+
result = risk_assessor.risk_summary
|
|
309
|
+
expect(result[:by_tier]).to be_a(Hash)
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
it 'by_tier includes all risk tiers' do
|
|
313
|
+
result = risk_assessor.risk_summary
|
|
314
|
+
Legion::Extensions::MindGrowth::Helpers::Constants::RISK_TIERS.each do |tier|
|
|
315
|
+
expect(result[:by_tier]).to have_key(tier)
|
|
316
|
+
end
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
it 'returns total 0 when no proposals exist' do
|
|
320
|
+
result = risk_assessor.risk_summary
|
|
321
|
+
expect(result[:total]).to eq(0)
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
it 'all tier arrays are empty when no proposals' do
|
|
325
|
+
result = risk_assessor.risk_summary
|
|
326
|
+
result[:by_tier].each_value { |arr| expect(arr).to eq([]) }
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
context 'with proposals in the store' do
|
|
330
|
+
before do
|
|
331
|
+
create_proposal(name: 'lex-rs1', category: :communication, helpers: [], runner_methods: [])
|
|
332
|
+
create_proposal(name: 'lex-rs2', category: :safety, helpers: [], runner_methods: [])
|
|
333
|
+
create_proposal(name: 'lex-rs3', category: :cognition, helpers: [], runner_methods: [])
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
it 'counts total proposals assessed' do
|
|
337
|
+
result = risk_assessor.risk_summary
|
|
338
|
+
expect(result[:total]).to eq(3)
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
it 'places low-risk proposal in :low tier' do
|
|
342
|
+
result = risk_assessor.risk_summary
|
|
343
|
+
low_names = result[:by_tier][:low].map { |r| r[:proposal_id] }
|
|
344
|
+
proposal = proposer.list_proposals[:proposals].find { |p| p[:name] == 'lex-rs1' }
|
|
345
|
+
expect(low_names).to include(proposal[:id])
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
it 'places high-risk proposal in :high tier' do
|
|
349
|
+
result = risk_assessor.risk_summary
|
|
350
|
+
expect(result[:by_tier][:high].size).to eq(1)
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
it 'places medium-risk proposal in :medium tier' do
|
|
354
|
+
result = risk_assessor.risk_summary
|
|
355
|
+
expect(result[:by_tier][:medium].size).to eq(1)
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
context 'with explicit proposals array' do
|
|
360
|
+
it 'accepts proposals as array of hashes with :id keys' do
|
|
361
|
+
proposal = create_proposal(name: 'lex-explicit', category: :communication)
|
|
362
|
+
result = risk_assessor.risk_summary(proposals: [{ id: proposal.id }])
|
|
363
|
+
expect(result[:total]).to eq(1)
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
it 'accepts proposals as array of plain id strings' do
|
|
367
|
+
proposal = create_proposal(name: 'lex-str', category: :communication)
|
|
368
|
+
result = risk_assessor.risk_summary(proposals: [proposal.id])
|
|
369
|
+
expect(result[:total]).to eq(1)
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
it 'skips unknown ids gracefully' do
|
|
373
|
+
result = risk_assessor.risk_summary(proposals: ['nonexistent-id'])
|
|
374
|
+
expect(result[:total]).to eq(0)
|
|
375
|
+
end
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
# ─── constant checks ──────────────────────────────────────────────────────
|
|
380
|
+
|
|
381
|
+
describe 'constants' do
|
|
382
|
+
it 'RISK_TIERS contains :low, :medium, :high, :critical' do
|
|
383
|
+
expect(Legion::Extensions::MindGrowth::Helpers::Constants::RISK_TIERS)
|
|
384
|
+
.to contain_exactly(:low, :medium, :high, :critical)
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
it 'RISK_RECOMMENDATIONS maps all tiers' do
|
|
388
|
+
recs = Legion::Extensions::MindGrowth::Helpers::Constants::RISK_RECOMMENDATIONS
|
|
389
|
+
expect(recs[:low]).to eq(:auto_approve)
|
|
390
|
+
expect(recs[:medium]).to eq(:governance)
|
|
391
|
+
expect(recs[:high]).to eq(:human_required)
|
|
392
|
+
expect(recs[:critical]).to eq(:blocked)
|
|
393
|
+
end
|
|
394
|
+
end
|
|
395
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: lex-mind-growth
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.7
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Esity
|
|
@@ -143,10 +143,12 @@ files:
|
|
|
143
143
|
- lib/legion/extensions/mind_growth/helpers/proposal_store.rb
|
|
144
144
|
- lib/legion/extensions/mind_growth/runners/analyzer.rb
|
|
145
145
|
- lib/legion/extensions/mind_growth/runners/builder.rb
|
|
146
|
+
- lib/legion/extensions/mind_growth/runners/governance.rb
|
|
146
147
|
- lib/legion/extensions/mind_growth/runners/integration_tester.rb
|
|
147
148
|
- lib/legion/extensions/mind_growth/runners/orchestrator.rb
|
|
148
149
|
- lib/legion/extensions/mind_growth/runners/proposer.rb
|
|
149
150
|
- lib/legion/extensions/mind_growth/runners/retrospective.rb
|
|
151
|
+
- lib/legion/extensions/mind_growth/runners/risk_assessor.rb
|
|
150
152
|
- lib/legion/extensions/mind_growth/runners/validator.rb
|
|
151
153
|
- lib/legion/extensions/mind_growth/runners/wirer.rb
|
|
152
154
|
- lib/legion/extensions/mind_growth/version.rb
|
|
@@ -160,10 +162,12 @@ files:
|
|
|
160
162
|
- spec/legion/extensions/mind_growth/helpers/proposal_store_spec.rb
|
|
161
163
|
- spec/legion/extensions/mind_growth/runners/analyzer_spec.rb
|
|
162
164
|
- spec/legion/extensions/mind_growth/runners/builder_spec.rb
|
|
165
|
+
- spec/legion/extensions/mind_growth/runners/governance_spec.rb
|
|
163
166
|
- spec/legion/extensions/mind_growth/runners/integration_tester_spec.rb
|
|
164
167
|
- spec/legion/extensions/mind_growth/runners/orchestrator_spec.rb
|
|
165
168
|
- spec/legion/extensions/mind_growth/runners/proposer_spec.rb
|
|
166
169
|
- spec/legion/extensions/mind_growth/runners/retrospective_spec.rb
|
|
170
|
+
- spec/legion/extensions/mind_growth/runners/risk_assessor_spec.rb
|
|
167
171
|
- spec/legion/extensions/mind_growth/runners/validator_spec.rb
|
|
168
172
|
- spec/legion/extensions/mind_growth/runners/wirer_spec.rb
|
|
169
173
|
- spec/spec_helper.rb
|