lex-mind-growth 0.1.6 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '0855bf68cbc39002e5c08851edd6f3ce71afddb38f0633078a68d878fe954dec'
4
- data.tar.gz: dc106552129f3d41f91711c51fca3b2a90a0198aede48d14b4d589c0b319fea0
3
+ metadata.gz: 87d35372e2ef409b603807723d816df60439d40bc8f4289d48799d31b731d232
4
+ data.tar.gz: fd49c0c8bb06f49374cbbb3afb497f45ee58193e71ff66b1ef1c622b94bbeaa6
5
5
  SHA512:
6
- metadata.gz: 7d423da820af5d21f8b8f33f3c86850ae6cc6734d717386d31a862b800573694148626ade4587f5d2dbf10bafa14a61f0c973a2b1cefb1fe92817ef6c38527cd
7
- data.tar.gz: 613aabc82f554a7de59965e8bb6a6543a72e346e74ea6618a6de65c15b4b40a2d2c4220230281dbf51e613aaa87d06b40738988627137244e9c7247229aef1be
6
+ metadata.gz: d3c07088c0173a6e5669af58bf3fbd315f2ef9098fa9e3f3f65228815159e6a061860c05aba14b75a0f0dcd9f01aea50f5a89ec9455a5796484037c6e1571328
7
+ data.tar.gz: 98b9b07557efbb5a3546a4018300ab984a0bfecc24ae93ab2534c35c90b991877d5564863460a75137525b262c996b7536ca14c6bbd20f77671225a8501ba402
@@ -35,6 +35,39 @@ 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(**)
50
+
51
+ # Monitor delegation
52
+ def health_check(**) = Runners::Monitor.health_check(**)
53
+ def usage_stats(**) = Runners::Monitor.usage_stats(**)
54
+ def impact_score(**) = Runners::Monitor.impact_score(**)
55
+ def decay_check(**) = Runners::Monitor.decay_check(**)
56
+ def auto_prune(**) = Runners::Monitor.auto_prune(**)
57
+ def health_summary(**) = Runners::Monitor.health_summary(**)
58
+
59
+ # Composer delegation
60
+ def add_composition(**) = Runners::Composer.add_composition(**)
61
+ def remove_composition(**) = Runners::Composer.remove_composition(**)
62
+ def evaluate_output(**) = Runners::Composer.evaluate_output(**)
63
+ def composition_stats(**) = Runners::Composer.composition_stats(**)
64
+ def suggest_compositions(**) = Runners::Composer.suggest_compositions(**)
65
+ def list_compositions(**) = Runners::Composer.list_compositions(**)
66
+
67
+ # DreamIdeation delegation
68
+ def generate_dream_proposals(**) = Runners::DreamIdeation.generate_dream_proposals(**)
69
+ def dream_agenda_items(**) = Runners::DreamIdeation.dream_agenda_items(**)
70
+ def enrich_from_dream_context(**) = Runners::DreamIdeation.enrich_from_dream_context(**)
38
71
  end
39
72
  end
40
73
  end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module MindGrowth
6
+ module Helpers
7
+ module CompositionMap
8
+ module_function
9
+
10
+ @rules = {}
11
+ @mutex = Mutex.new
12
+
13
+ def add_rule(source_extension:, output_key:, target_extension:, target_method:, transform: nil, **)
14
+ rule_id = SecureRandom.uuid
15
+ rule = {
16
+ id: rule_id,
17
+ source_extension: source_extension.to_s,
18
+ output_key: output_key.to_sym,
19
+ target_extension: target_extension.to_s,
20
+ target_method: target_method.to_sym,
21
+ transform: transform
22
+ }
23
+ @mutex.synchronize { @rules[rule_id] = rule }
24
+ { success: true, rule_id: rule_id }
25
+ end
26
+
27
+ def remove_rule(rule_id:, **)
28
+ removed = @mutex.synchronize { @rules.delete(rule_id) }
29
+ { success: !removed.nil?, rule_id: rule_id }
30
+ end
31
+
32
+ def rules_for(source_extension:, **)
33
+ src = source_extension.to_s
34
+ @mutex.synchronize { @rules.values.select { |r| r[:source_extension] == src } }
35
+ end
36
+
37
+ def all_rules
38
+ @mutex.synchronize { @rules.values.dup }
39
+ end
40
+
41
+ def match_output(source_extension:, output:, **)
42
+ src = source_extension.to_s
43
+ out_h = output.is_a?(Hash) ? output : {}
44
+ rules = @mutex.synchronize { @rules.values.select { |r| r[:source_extension] == src } }
45
+
46
+ rules.filter_map do |rule|
47
+ key = rule[:output_key]
48
+ next unless out_h.key?(key)
49
+
50
+ { rule: rule, matched_value: out_h[key] }
51
+ end
52
+ end
53
+
54
+ def clear!
55
+ @mutex.synchronize { @rules.clear }
56
+ end
57
+
58
+ def stats
59
+ all = @mutex.synchronize { @rules.values.dup }
60
+
61
+ by_source = Hash.new(0)
62
+ by_target = Hash.new(0)
63
+ all.each do |r|
64
+ by_source[r[:source_extension]] += 1
65
+ by_target[r[:target_extension]] += 1
66
+ end
67
+
68
+ { total_rules: all.size,
69
+ by_source: by_source.transform_values { |v| v },
70
+ by_target: by_target.transform_values { |v| v } }
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
@@ -48,6 +48,24 @@ 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
+ # Health monitoring
58
+ HEALTH_LEVELS = { excellent: 0.8, good: 0.6, fair: 0.4, degraded: 0.2, critical: 0.0 }.freeze
59
+ DECAY_INVOCATION_THRESHOLD = 5
60
+
61
+ # Risk assessment
62
+ RISK_TIERS = %i[low medium high critical].freeze
63
+ RISK_RECOMMENDATIONS = {
64
+ low: :auto_approve,
65
+ medium: :governance,
66
+ high: :human_required,
67
+ critical: :blocked
68
+ }.freeze
51
69
  end
52
70
  end
53
71
  end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module MindGrowth
6
+ module Runners
7
+ module Composer
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
+ # Category adjacency used for heuristic suggestions
14
+ CATEGORY_FLOW = [
15
+ %i[perception cognition],
16
+ %i[cognition memory],
17
+ %i[cognition introspection],
18
+ %i[memory cognition],
19
+ %i[introspection safety],
20
+ %i[motivation cognition],
21
+ %i[cognition communication],
22
+ %i[communication coordination]
23
+ ].freeze
24
+
25
+ def add_composition(source_extension:, output_key:, target_extension:, target_method:,
26
+ transform: nil, **)
27
+ Helpers::CompositionMap.add_rule(
28
+ source_extension: source_extension,
29
+ output_key: output_key,
30
+ target_extension: target_extension,
31
+ target_method: target_method,
32
+ transform: transform
33
+ )
34
+ end
35
+
36
+ def remove_composition(rule_id:, **)
37
+ result = Helpers::CompositionMap.remove_rule(rule_id: rule_id)
38
+ { success: result[:success] }
39
+ end
40
+
41
+ def evaluate_output(source_extension:, output:, **)
42
+ matches = Helpers::CompositionMap.match_output(
43
+ source_extension: source_extension,
44
+ output: output
45
+ )
46
+
47
+ dispatches = matches.map do |match|
48
+ rule = match[:rule]
49
+ value = match[:matched_value]
50
+ input = rule[:transform] ? rule[:transform].call(value) : value
51
+
52
+ { target_extension: rule[:target_extension],
53
+ target_method: rule[:target_method],
54
+ input: input }
55
+ end
56
+
57
+ { success: true, dispatches: dispatches, count: dispatches.size }
58
+ end
59
+
60
+ def composition_stats(**)
61
+ { success: true, **Helpers::CompositionMap.stats }
62
+ end
63
+
64
+ def suggest_compositions(extensions:, **)
65
+ exts = Array(extensions)
66
+
67
+ return suggest_with_llm(exts) if defined?(Legion::LLM) && Legion::LLM.respond_to?(:started?) && Legion::LLM.started?
68
+
69
+ suggestions = heuristic_suggestions(exts)
70
+ { success: true, suggestions: suggestions, count: suggestions.size }
71
+ end
72
+
73
+ def list_compositions(**)
74
+ rules = Helpers::CompositionMap.all_rules
75
+ { success: true, rules: rules, count: rules.size }
76
+ end
77
+
78
+ private
79
+
80
+ def heuristic_suggestions(extensions)
81
+ ext_by_category = {}
82
+ extensions.each do |ext|
83
+ cat = (ext[:category] || :cognition).to_sym
84
+ (ext_by_category[cat] ||= []) << ext
85
+ end
86
+
87
+ suggestions = []
88
+ CATEGORY_FLOW.each do |src_cat, tgt_cat|
89
+ src_exts = ext_by_category[src_cat] || []
90
+ tgt_exts = ext_by_category[tgt_cat] || []
91
+
92
+ src_exts.each do |src|
93
+ tgt_exts.each do |tgt|
94
+ suggestions << {
95
+ source_extension: src[:name] || src[:extension_name],
96
+ output_key: :result,
97
+ target_extension: tgt[:name] || tgt[:extension_name],
98
+ target_method: :process,
99
+ rationale: "#{src_cat} -> #{tgt_cat} flow"
100
+ }
101
+ end
102
+ end
103
+ end
104
+
105
+ suggestions
106
+ end
107
+
108
+ def suggest_with_llm(extensions)
109
+ suggestions = heuristic_suggestions(extensions)
110
+ { success: true, suggestions: suggestions, count: suggestions.size }
111
+ rescue StandardError
112
+ { success: true, suggestions: [], count: 0 }
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module MindGrowth
6
+ module Runners
7
+ module DreamIdeation
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
+ DREAM_NOVELTY_BONUS = 0.15
14
+
15
+ # Agenda item weight by how underrepresented the category is
16
+ MAX_AGENDA_WEIGHT = 1.0
17
+ MIN_AGENDA_WEIGHT = 0.1
18
+
19
+ def generate_dream_proposals(existing_extensions: nil, max_proposals: 2, **)
20
+ gap_result = Runners::Proposer.analyze_gaps(existing_extensions: existing_extensions)
21
+ return { success: false, error: :gap_analysis_failed } unless gap_result[:success]
22
+
23
+ recommendations = gap_result[:recommendations] || []
24
+ proposals = []
25
+
26
+ recommendations.first(max_proposals).each do |rec|
27
+ name = rec.is_a?(Hash) ? rec[:name] : rec.to_s
28
+ result = Runners::Proposer.propose_concept(
29
+ name: "lex-dream-#{name.to_s.downcase.gsub(/[^a-z0-9]/, '-')}",
30
+ description: "Dream-originated proposal for #{name} cognitive capability",
31
+ enrich: false
32
+ )
33
+ next unless result[:success]
34
+
35
+ proposal_id = result[:proposal][:id]
36
+ proposal = Runners::Proposer.get_proposal_object(proposal_id)
37
+ proposal&.instance_variable_set(:@origin, :dream)
38
+
39
+ proposals << result[:proposal]
40
+ end
41
+
42
+ { success: true, proposals: proposals, count: proposals.size,
43
+ gaps_analyzed: recommendations.size }
44
+ end
45
+
46
+ def dream_agenda_items(existing_extensions: nil, **)
47
+ gap_result = Runners::Proposer.analyze_gaps(existing_extensions: existing_extensions)
48
+ return { success: false, error: :gap_analysis_failed } unless gap_result[:success]
49
+
50
+ target = Helpers::Constants::TARGET_DISTRIBUTION
51
+ models = gap_result[:models] || []
52
+
53
+ coverage_by_cat = build_coverage_by_category(models)
54
+
55
+ items = target.filter_map do |category, target_pct|
56
+ actual_pct = coverage_by_cat[category] || 0.0
57
+ gap = (target_pct - actual_pct).clamp(0.0, 1.0)
58
+ next if gap <= 0.0
59
+
60
+ weight = ((gap / target_pct) * MAX_AGENDA_WEIGHT).clamp(MIN_AGENDA_WEIGHT, MAX_AGENDA_WEIGHT).round(3)
61
+
62
+ { type: :architectural_gap,
63
+ content: { gap_name: category, model: :target_distribution, coverage: actual_pct },
64
+ weight: weight }
65
+ end
66
+
67
+ { success: true, items: items, count: items.size }
68
+ end
69
+
70
+ def enrich_from_dream_context(proposal_id:, dream_context: {}, **)
71
+ proposal = Runners::Proposer.get_proposal_object(proposal_id)
72
+ return { success: false, error: :not_found } unless proposal
73
+
74
+ if dream_context && !dream_context.empty?
75
+ existing = proposal.rationale.to_s
76
+ additions = dream_context.map { |k, v| "#{k}: #{v}" }.join('; ')
77
+ new_rationale = existing.empty? ? additions : "#{existing}. Dream context: #{additions}"
78
+ proposal.instance_variable_set(:@rationale, new_rationale)
79
+ { success: true, proposal_id: proposal_id, enriched: true }
80
+ else
81
+ { success: true, proposal_id: proposal_id, enriched: false }
82
+ end
83
+ end
84
+
85
+ private
86
+
87
+ def build_coverage_by_category(models)
88
+ coverage = {}
89
+ models.each do |model|
90
+ cat = infer_category_from_model(model[:model])
91
+ next unless cat
92
+
93
+ existing = coverage[cat] || 1.0
94
+ coverage[cat] = [existing, model_coverage_fraction(model)].min
95
+ end
96
+ coverage
97
+ end
98
+
99
+ def model_coverage_fraction(model)
100
+ total = model[:total_required] || 1
101
+ missing = (model[:missing] || []).size
102
+ covered = total - missing
103
+ total.positive? ? (covered.to_f / total).round(3) : 0.0
104
+ end
105
+
106
+ def infer_category_from_model(model_name)
107
+ mapping = {
108
+ global_workspace: :cognition,
109
+ free_energy: :introspection,
110
+ dual_process: :cognition,
111
+ somatic_marker: :motivation,
112
+ working_memory: :memory
113
+ }
114
+ mapping[model_name&.to_sym]
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
120
+ 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,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module MindGrowth
6
+ module Runners
7
+ module Monitor
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
+ HEALTH_LEVELS = {
14
+ excellent: 0.8,
15
+ good: 0.6,
16
+ fair: 0.4,
17
+ degraded: 0.2,
18
+ critical: 0.0
19
+ }.freeze
20
+
21
+ def health_check(extension:, **)
22
+ name = extension[:name] || extension[:extension_name]
23
+ fitness = Helpers::FitnessEvaluator.fitness(extension)
24
+ level = classify_health(fitness)
25
+ alert = %i[degraded critical].include?(level)
26
+
27
+ { success: true, extension_name: name, fitness: fitness,
28
+ health_level: level, alert: alert }
29
+ end
30
+
31
+ def usage_stats(extensions:, **)
32
+ stats = Array(extensions).map do |ext|
33
+ { extension_name: ext[:name] || ext[:extension_name],
34
+ invocation_count: ext[:invocation_count] || 0,
35
+ error_rate: ext[:error_rate] || 0.0,
36
+ avg_latency_ms: ext[:avg_latency_ms] || 0 }
37
+ end
38
+
39
+ { success: true, stats: stats, count: stats.size }
40
+ end
41
+
42
+ def impact_score(extension:, extensions: nil, **)
43
+ name = extension[:name] || extension[:extension_name]
44
+ impact = extension[:impact_score] || 0.5
45
+
46
+ percentile = if extensions && !Array(extensions).empty?
47
+ all_impacts = Array(extensions).map { |e| e[:impact_score] || 0.5 }.sort
48
+ rank = all_impacts.count { |i| i <= impact }
49
+ (rank.to_f / all_impacts.size * 100).round(1)
50
+ else
51
+ 50.0
52
+ end
53
+
54
+ { success: true, extension_name: name, impact: impact, rank_percentile: percentile }
55
+ end
56
+
57
+ def decay_check(extensions:, **)
58
+ threshold = Helpers::Constants::DECAY_INVOCATION_THRESHOLD
59
+ decayed = Array(extensions).select do |ext|
60
+ count = ext[:invocation_count] || 0
61
+ fitness = Helpers::FitnessEvaluator.fitness(ext)
62
+ count < threshold || fitness < Helpers::Constants::PRUNE_THRESHOLD
63
+ end
64
+
65
+ { success: true, decayed: decayed, count: decayed.size }
66
+ end
67
+
68
+ def auto_prune(extensions:, **)
69
+ pruned = Helpers::FitnessEvaluator.prune_candidates(Array(extensions))
70
+ { success: true, pruned: pruned, count: pruned.size }
71
+ end
72
+
73
+ def health_summary(extensions:, **)
74
+ exts = Array(extensions)
75
+
76
+ by_health_level = HEALTH_LEVELS.keys.to_h { |level| [level, 0] }
77
+ alerts = []
78
+ prune_candidates = Helpers::FitnessEvaluator.prune_candidates(exts)
79
+
80
+ exts.each do |ext|
81
+ fitness = Helpers::FitnessEvaluator.fitness(ext)
82
+ level = classify_health(fitness)
83
+ by_health_level[level] += 1
84
+ alerts << ext if %i[degraded critical].include?(level)
85
+ end
86
+
87
+ { success: true, total: exts.size, by_health_level: by_health_level,
88
+ alerts: alerts, prune_candidates: prune_candidates }
89
+ end
90
+
91
+ private
92
+
93
+ def classify_health(fitness)
94
+ HEALTH_LEVELS.each do |level, threshold|
95
+ return level if fitness >= threshold
96
+ end
97
+ :critical
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end