lex-social-learning 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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: a099b9a518340e711699d958e1de852e7d04d420adca67b0d67f96c7ecbe92a9
4
+ data.tar.gz: 42bb370a5586e73d9d8ba4650103091345f915b8a952aefa5057a1afb233594c
5
+ SHA512:
6
+ metadata.gz: 89887a76ef663f2d17fa65a20a78d350849791c7916a4f1c26fd14e4cd20ffc93a93fef6edfb0ec428dce93f70bd9f620e907d2a30879ca609ef54d93f87ccf5
7
+ data.tar.gz: 810ac3320303238b05e2b1107bb3def16f48a87cb455867b4a1abac2eadc5430de45ec1402cbe1befcd5561c050619326e5cd9b04ee07e9978a6cc20bbae1311
data/Gemfile ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ gemspec
6
+
7
+ gem 'rspec', '~> 3.13'
8
+ gem 'rubocop', '~> 1.75', require: false
9
+ gem 'rubocop-rspec', require: false
10
+
11
+ gem 'legion-gaia', path: '../../legion-gaia'
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'lib/legion/extensions/social_learning/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'lex-social-learning'
7
+ spec.version = Legion::Extensions::SocialLearning::VERSION
8
+ spec.authors = ['Esity']
9
+ spec.email = ['matthewdiverson@gmail.com']
10
+
11
+ spec.summary = 'LEX Social Learning'
12
+ spec.description = "Bandura's Social Cognitive Theory for LegionIO: vicarious learning via model observation and reproduction"
13
+ spec.homepage = 'https://github.com/LegionIO/lex-social-learning'
14
+ spec.license = 'MIT'
15
+ spec.required_ruby_version = '>= 3.4'
16
+
17
+ spec.metadata['homepage_uri'] = spec.homepage
18
+ spec.metadata['source_code_uri'] = 'https://github.com/LegionIO/lex-social-learning'
19
+ spec.metadata['documentation_uri'] = 'https://github.com/LegionIO/lex-social-learning'
20
+ spec.metadata['changelog_uri'] = 'https://github.com/LegionIO/lex-social-learning'
21
+ spec.metadata['bug_tracker_uri'] = 'https://github.com/LegionIO/lex-social-learning/issues'
22
+ spec.metadata['rubygems_mfa_required'] = 'true'
23
+
24
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
25
+ Dir.glob('{lib,spec}/**/*') + %w[lex-social-learning.gemspec Gemfile]
26
+ end
27
+ spec.require_paths = ['lib']
28
+ spec.add_development_dependency 'legion-gaia'
29
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/social_learning/helpers/constants'
4
+ require 'legion/extensions/social_learning/helpers/observed_behavior'
5
+ require 'legion/extensions/social_learning/helpers/model_agent'
6
+ require 'legion/extensions/social_learning/helpers/social_learning_engine'
7
+ require 'legion/extensions/social_learning/runners/social_learning'
8
+
9
+ module Legion
10
+ module Extensions
11
+ module SocialLearning
12
+ class Client
13
+ include Runners::SocialLearning
14
+
15
+ def initialize(engine: nil)
16
+ @engine = engine || Helpers::SocialLearningEngine.new
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module SocialLearning
6
+ module Helpers
7
+ module Constants
8
+ MAX_MODELS = 200
9
+ MAX_BEHAVIORS = 500
10
+ MAX_OBSERVATIONS = 1000
11
+ MAX_HISTORY = 300
12
+
13
+ DEFAULT_PRESTIGE = 0.5
14
+ PRESTIGE_FLOOR = 0.0
15
+ PRESTIGE_CEILING = 1.0
16
+ ATTENTION_THRESHOLD = 0.3
17
+ RETENTION_DECAY = 0.02
18
+ REPRODUCTION_CONFIDENCE = 0.5
19
+ REINFORCEMENT_BOOST = 0.15
20
+ PUNISHMENT_PENALTY = 0.2
21
+ PRESTIGE_LEARNING_RATE = 0.1
22
+ STALE_THRESHOLD = 120
23
+
24
+ OUTCOME_TYPES = %i[positive negative neutral].freeze
25
+ LEARNING_STAGES = %i[attention retention reproduction motivation].freeze
26
+
27
+ MODEL_LABELS = {
28
+ (0.8..) => :expert,
29
+ (0.6...0.8) => :proficient,
30
+ (0.4...0.6) => :peer,
31
+ (0.2...0.4) => :novice,
32
+ (..0.2) => :unreliable
33
+ }.freeze
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module Legion
6
+ module Extensions
7
+ module SocialLearning
8
+ module Helpers
9
+ class ModelAgent
10
+ include Constants
11
+
12
+ attr_reader :id, :agent_id, :domain, :observation_count,
13
+ :success_count, :created_at, :last_observed_at,
14
+ :observed_behaviors
15
+ attr_accessor :prestige
16
+
17
+ def initialize(agent_id:, domain:, prestige: Constants::DEFAULT_PRESTIGE)
18
+ @id = SecureRandom.uuid
19
+ @agent_id = agent_id
20
+ @domain = domain
21
+ @prestige = prestige.clamp(Constants::PRESTIGE_FLOOR, Constants::PRESTIGE_CEILING)
22
+ @observed_behaviors = []
23
+ @observation_count = 0
24
+ @success_count = 0
25
+ @created_at = Time.now.utc
26
+ @last_observed_at = nil
27
+ end
28
+
29
+ def observe!(behavior:, outcome:)
30
+ @observation_count += 1
31
+ @last_observed_at = Time.now.utc
32
+
33
+ if outcome == :positive
34
+ @success_count += 1
35
+ @prestige = (@prestige + Constants::PRESTIGE_LEARNING_RATE).clamp(
36
+ Constants::PRESTIGE_FLOOR,
37
+ Constants::PRESTIGE_CEILING
38
+ )
39
+ elsif outcome == :negative
40
+ @prestige = (@prestige - Constants::PRESTIGE_LEARNING_RATE).clamp(
41
+ Constants::PRESTIGE_FLOOR,
42
+ Constants::PRESTIGE_CEILING
43
+ )
44
+ end
45
+
46
+ behavior
47
+ end
48
+
49
+ def prestige_label
50
+ Constants::MODEL_LABELS.find { |range, _label| range.include?(@prestige) }&.last || :unknown
51
+ end
52
+
53
+ def success_rate
54
+ return 0.0 if @observation_count.zero?
55
+
56
+ (@success_count.to_f / @observation_count).round(4)
57
+ end
58
+
59
+ def to_h
60
+ {
61
+ id: @id,
62
+ agent_id: @agent_id,
63
+ domain: @domain,
64
+ prestige: @prestige.round(4),
65
+ prestige_label: prestige_label,
66
+ observation_count: @observation_count,
67
+ success_count: @success_count,
68
+ success_rate: success_rate,
69
+ behavior_count: @observed_behaviors.size,
70
+ created_at: @created_at,
71
+ last_observed_at: @last_observed_at
72
+ }
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module Legion
6
+ module Extensions
7
+ module SocialLearning
8
+ module Helpers
9
+ class ObservedBehavior
10
+ include Constants
11
+
12
+ attr_reader :id, :model_agent_id, :action, :domain, :context,
13
+ :outcome, :created_at
14
+ attr_accessor :retention, :reproduced
15
+
16
+ def initialize(model_agent_id:, action:, domain:, outcome:, context: {})
17
+ @id = SecureRandom.uuid
18
+ @model_agent_id = model_agent_id
19
+ @action = action
20
+ @domain = domain
21
+ @context = context
22
+ @outcome = outcome
23
+ @retention = 1.0
24
+ @reproduced = false
25
+ @created_at = Time.now.utc
26
+ end
27
+
28
+ def decay_retention!
29
+ @retention = (@retention - Constants::RETENTION_DECAY).clamp(
30
+ Constants::PRESTIGE_FLOOR,
31
+ Constants::PRESTIGE_CEILING
32
+ )
33
+ end
34
+
35
+ def retained?
36
+ @retention >= Constants::REPRODUCTION_CONFIDENCE
37
+ end
38
+
39
+ def to_h
40
+ {
41
+ id: @id,
42
+ model_agent_id: @model_agent_id,
43
+ action: @action,
44
+ domain: @domain,
45
+ context: @context,
46
+ outcome: @outcome,
47
+ retention: @retention.round(4),
48
+ reproduced: @reproduced,
49
+ retained: retained?,
50
+ created_at: @created_at
51
+ }
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module SocialLearning
6
+ module Helpers
7
+ class SocialLearningEngine
8
+ include Constants
9
+
10
+ def initialize
11
+ @models = {} # id -> ModelAgent
12
+ @behaviors = {} # id -> ObservedBehavior
13
+ end
14
+
15
+ def register_model(agent_id:, domain:, prestige: Constants::DEFAULT_PRESTIGE)
16
+ prune_models if @models.size >= Constants::MAX_MODELS
17
+
18
+ model = ModelAgent.new(agent_id: agent_id, domain: domain, prestige: prestige)
19
+ @models[model.id] = model
20
+ model
21
+ end
22
+
23
+ def observe_behavior(model_id:, action:, domain:, outcome:, context: {})
24
+ model = @models.fetch(model_id, nil)
25
+ return nil unless model
26
+ return nil if model.prestige < Constants::ATTENTION_THRESHOLD
27
+
28
+ prune_behaviors if @behaviors.size >= Constants::MAX_BEHAVIORS
29
+
30
+ behavior = ObservedBehavior.new(
31
+ model_agent_id: model_id,
32
+ action: action,
33
+ domain: domain,
34
+ outcome: outcome,
35
+ context: context
36
+ )
37
+
38
+ model.observe!(behavior: behavior, outcome: outcome)
39
+ model.observed_behaviors << behavior
40
+ @behaviors[behavior.id] = behavior
41
+ behavior
42
+ end
43
+
44
+ def retained_behaviors(domain: nil)
45
+ behaviors = @behaviors.values.select(&:retained?)
46
+ return behaviors unless domain
47
+
48
+ behaviors.select { |beh| beh.domain == domain }
49
+ end
50
+
51
+ def reproducible_behaviors(domain: nil)
52
+ behaviors = retained_behaviors(domain: domain)
53
+ behaviors.select { |beh| beh.retention >= Constants::REPRODUCTION_CONFIDENCE }
54
+ end
55
+
56
+ def reproduce_behavior(behavior_id:)
57
+ behavior = @behaviors.fetch(behavior_id, nil)
58
+ return nil unless behavior
59
+ return nil unless behavior.retained?
60
+
61
+ behavior.reproduced = true
62
+ behavior
63
+ end
64
+
65
+ def reinforce_reproduction(behavior_id:, outcome:)
66
+ behavior = @behaviors.fetch(behavior_id, nil)
67
+ return nil unless behavior
68
+
69
+ model = @models.fetch(behavior.model_agent_id, nil)
70
+ return nil unless model
71
+
72
+ case outcome
73
+ when :positive
74
+ model.prestige = (model.prestige + Constants::REINFORCEMENT_BOOST).clamp(
75
+ Constants::PRESTIGE_FLOOR,
76
+ Constants::PRESTIGE_CEILING
77
+ )
78
+ when :negative
79
+ model.prestige = (model.prestige - Constants::PUNISHMENT_PENALTY).clamp(
80
+ Constants::PRESTIGE_FLOOR,
81
+ Constants::PRESTIGE_CEILING
82
+ )
83
+ end
84
+
85
+ { behavior: behavior.to_h, model_prestige: model.prestige.round(4) }
86
+ end
87
+
88
+ def best_models(limit: 5)
89
+ @models.values
90
+ .sort_by { |mod| -mod.prestige }
91
+ .first(limit)
92
+ end
93
+
94
+ def by_domain(domain:)
95
+ @models.values.select { |mod| mod.domain == domain }
96
+ end
97
+
98
+ def decay_all
99
+ @behaviors.each_value(&:decay_retention!)
100
+ end
101
+
102
+ def prune_forgotten
103
+ @behaviors.delete_if { |_id, beh| beh.retention < 0.05 }
104
+ end
105
+
106
+ def to_h
107
+ {
108
+ model_count: @models.size,
109
+ behavior_count: @behaviors.size,
110
+ retained_count: retained_behaviors.size,
111
+ reproducible_count: reproducible_behaviors.size
112
+ }
113
+ end
114
+
115
+ private
116
+
117
+ def prune_models
118
+ oldest = @models.values.min_by(&:created_at)
119
+ @models.delete(oldest.id) if oldest
120
+ end
121
+
122
+ def prune_behaviors
123
+ oldest = @behaviors.values.min_by(&:created_at)
124
+ @behaviors.delete(oldest.id) if oldest
125
+ end
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module SocialLearning
6
+ module Runners
7
+ module SocialLearning
8
+ include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers) &&
9
+ Legion::Extensions::Helpers.const_defined?(:Lex)
10
+
11
+ def register_model_agent(agent_id:, domain:, prestige: nil, **)
12
+ init_prestige = prestige || Helpers::Constants::DEFAULT_PRESTIGE
13
+ Legion::Logging.debug "[social_learning] register_model agent=#{agent_id} domain=#{domain} prestige=#{init_prestige}"
14
+ model = engine.register_model(agent_id: agent_id, domain: domain, prestige: init_prestige)
15
+ { success: true, model: model.to_h }
16
+ end
17
+
18
+ def observe_agent_behavior(model_id:, action:, domain:, outcome:, context: {}, **)
19
+ Legion::Logging.debug "[social_learning] observe model=#{model_id} action=#{action} domain=#{domain} outcome=#{outcome}"
20
+ behavior = engine.observe_behavior(
21
+ model_id: model_id,
22
+ action: action,
23
+ domain: domain,
24
+ outcome: outcome,
25
+ context: context
26
+ )
27
+ if behavior
28
+ { success: true, behavior: behavior.to_h }
29
+ else
30
+ { success: false, reason: 'model not found or below attention threshold' }
31
+ end
32
+ end
33
+
34
+ def retained_behaviors(domain: nil, **)
35
+ Legion::Logging.debug "[social_learning] retained_behaviors domain=#{domain.inspect}"
36
+ behaviors = engine.retained_behaviors(domain: domain)
37
+ { success: true, behaviors: behaviors.map(&:to_h), count: behaviors.size }
38
+ end
39
+
40
+ def reproducible_behaviors(domain: nil, **)
41
+ Legion::Logging.debug "[social_learning] reproducible_behaviors domain=#{domain.inspect}"
42
+ behaviors = engine.reproducible_behaviors(domain: domain)
43
+ { success: true, behaviors: behaviors.map(&:to_h), count: behaviors.size }
44
+ end
45
+
46
+ def reproduce_observed_behavior(behavior_id:, **)
47
+ Legion::Logging.debug "[social_learning] reproduce behavior_id=#{behavior_id}"
48
+ behavior = engine.reproduce_behavior(behavior_id: behavior_id)
49
+ if behavior
50
+ { success: true, behavior: behavior.to_h }
51
+ else
52
+ { success: false, reason: 'behavior not found or retention too low' }
53
+ end
54
+ end
55
+
56
+ def reinforce_reproduction(behavior_id:, outcome:, **)
57
+ Legion::Logging.debug "[social_learning] reinforce behavior_id=#{behavior_id} outcome=#{outcome}"
58
+ result = engine.reinforce_reproduction(behavior_id: behavior_id, outcome: outcome)
59
+ if result
60
+ { success: true }.merge(result)
61
+ else
62
+ { success: false, reason: 'behavior or model not found' }
63
+ end
64
+ end
65
+
66
+ def best_model_agents(limit: 5, **)
67
+ lim = limit.to_i
68
+ Legion::Logging.debug "[social_learning] best_model_agents limit=#{lim}"
69
+ models = engine.best_models(limit: lim)
70
+ { success: true, models: models.map(&:to_h), count: models.size }
71
+ end
72
+
73
+ def domain_models(domain:, **)
74
+ Legion::Logging.debug "[social_learning] domain_models domain=#{domain}"
75
+ models = engine.by_domain(domain: domain)
76
+ { success: true, models: models.map(&:to_h), count: models.size }
77
+ end
78
+
79
+ def update_social_learning(**)
80
+ Legion::Logging.debug '[social_learning] update_social_learning decay+prune cycle'
81
+ engine.decay_all
82
+ engine.prune_forgotten
83
+ stats = engine.to_h
84
+ { success: true }.merge(stats)
85
+ end
86
+
87
+ def social_learning_stats(**)
88
+ Legion::Logging.debug '[social_learning] social_learning_stats'
89
+ { success: true }.merge(engine.to_h)
90
+ end
91
+
92
+ private
93
+
94
+ def engine
95
+ @engine ||= Helpers::SocialLearningEngine.new
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module SocialLearning
6
+ VERSION = '0.1.0'
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/social_learning/version'
4
+ require 'legion/extensions/social_learning/helpers/constants'
5
+ require 'legion/extensions/social_learning/helpers/observed_behavior'
6
+ require 'legion/extensions/social_learning/helpers/model_agent'
7
+ require 'legion/extensions/social_learning/helpers/social_learning_engine'
8
+ require 'legion/extensions/social_learning/runners/social_learning'
9
+ require 'legion/extensions/social_learning/client'
10
+
11
+ module Legion
12
+ module Extensions
13
+ module SocialLearning
14
+ extend Legion::Extensions::Core if Legion::Extensions.const_defined? :Core
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::SocialLearning::Client do
4
+ let(:client) { described_class.new }
5
+
6
+ it 'responds to all runner methods' do
7
+ expect(client).to respond_to(:register_model_agent)
8
+ expect(client).to respond_to(:observe_agent_behavior)
9
+ expect(client).to respond_to(:retained_behaviors)
10
+ expect(client).to respond_to(:reproducible_behaviors)
11
+ expect(client).to respond_to(:reproduce_observed_behavior)
12
+ expect(client).to respond_to(:reinforce_reproduction)
13
+ expect(client).to respond_to(:best_model_agents)
14
+ expect(client).to respond_to(:domain_models)
15
+ expect(client).to respond_to(:update_social_learning)
16
+ expect(client).to respond_to(:social_learning_stats)
17
+ end
18
+
19
+ it 'accepts an injected engine' do
20
+ engine = Legion::Extensions::SocialLearning::Helpers::SocialLearningEngine.new
21
+ injected_client = described_class.new(engine: engine)
22
+ result = injected_client.social_learning_stats
23
+ expect(result[:success]).to be true
24
+ end
25
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::SocialLearning::Helpers::Constants do
4
+ it 'defines MAX_MODELS' do
5
+ expect(described_module::MAX_MODELS).to eq(200)
6
+ end
7
+
8
+ it 'defines MAX_BEHAVIORS' do
9
+ expect(described_module::MAX_BEHAVIORS).to eq(500)
10
+ end
11
+
12
+ it 'defines ATTENTION_THRESHOLD' do
13
+ expect(described_module::ATTENTION_THRESHOLD).to eq(0.3)
14
+ end
15
+
16
+ it 'defines REPRODUCTION_CONFIDENCE' do
17
+ expect(described_module::REPRODUCTION_CONFIDENCE).to eq(0.5)
18
+ end
19
+
20
+ it 'defines OUTCOME_TYPES' do
21
+ expect(described_module::OUTCOME_TYPES).to eq(%i[positive negative neutral])
22
+ end
23
+
24
+ it 'defines LEARNING_STAGES' do
25
+ expect(described_module::LEARNING_STAGES).to eq(%i[attention retention reproduction motivation])
26
+ end
27
+
28
+ it 'defines MODEL_LABELS mapping' do
29
+ expect(described_module::MODEL_LABELS).to be_a(Hash)
30
+ expect(described_module::MODEL_LABELS.size).to eq(5)
31
+ end
32
+
33
+ it 'PRESTIGE_FLOOR is 0.0' do
34
+ expect(described_module::PRESTIGE_FLOOR).to eq(0.0)
35
+ end
36
+
37
+ it 'PRESTIGE_CEILING is 1.0' do
38
+ expect(described_module::PRESTIGE_CEILING).to eq(1.0)
39
+ end
40
+
41
+ def described_module
42
+ Legion::Extensions::SocialLearning::Helpers::Constants
43
+ end
44
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::SocialLearning::Helpers::ModelAgent do
4
+ subject(:model) do
5
+ described_class.new(agent_id: 'agent-abc', domain: :ops, prestige: 0.5)
6
+ end
7
+
8
+ describe '#initialize' do
9
+ it 'assigns a uuid id' do
10
+ expect(model.id).to match(/\A[0-9a-f-]{36}\z/)
11
+ end
12
+
13
+ it 'sets the agent_id' do
14
+ expect(model.agent_id).to eq('agent-abc')
15
+ end
16
+
17
+ it 'sets prestige' do
18
+ expect(model.prestige).to eq(0.5)
19
+ end
20
+
21
+ it 'starts with zero observation_count' do
22
+ expect(model.observation_count).to eq(0)
23
+ end
24
+
25
+ it 'starts with empty observed_behaviors' do
26
+ expect(model.observed_behaviors).to be_empty
27
+ end
28
+
29
+ it 'clamps prestige above ceiling to 1.0' do
30
+ m = described_class.new(agent_id: 'x', domain: :test, prestige: 5.0)
31
+ expect(m.prestige).to eq(1.0)
32
+ end
33
+
34
+ it 'clamps prestige below floor to 0.0' do
35
+ m = described_class.new(agent_id: 'x', domain: :test, prestige: -1.0)
36
+ expect(m.prestige).to eq(0.0)
37
+ end
38
+ end
39
+
40
+ describe '#observe!' do
41
+ let(:fake_behavior) { double('ObservedBehavior') }
42
+
43
+ it 'increments observation_count' do
44
+ expect { model.observe!(behavior: fake_behavior, outcome: :neutral) }
45
+ .to change(model, :observation_count).by(1)
46
+ end
47
+
48
+ it 'increases prestige on positive outcome' do
49
+ before = model.prestige
50
+ model.observe!(behavior: fake_behavior, outcome: :positive)
51
+ expect(model.prestige).to be > before
52
+ end
53
+
54
+ it 'decreases prestige on negative outcome' do
55
+ before = model.prestige
56
+ model.observe!(behavior: fake_behavior, outcome: :negative)
57
+ expect(model.prestige).to be < before
58
+ end
59
+
60
+ it 'does not change prestige on neutral outcome' do
61
+ before = model.prestige
62
+ model.observe!(behavior: fake_behavior, outcome: :neutral)
63
+ expect(model.prestige).to eq(before)
64
+ end
65
+
66
+ it 'tracks success_count for positive outcomes' do
67
+ model.observe!(behavior: fake_behavior, outcome: :positive)
68
+ expect(model.success_count).to eq(1)
69
+ end
70
+ end
71
+
72
+ describe '#prestige_label' do
73
+ it 'returns :expert for prestige >= 0.8' do
74
+ model.prestige = 0.9
75
+ expect(model.prestige_label).to eq(:expert)
76
+ end
77
+
78
+ it 'returns :proficient for prestige in 0.6...0.8' do
79
+ model.prestige = 0.7
80
+ expect(model.prestige_label).to eq(:proficient)
81
+ end
82
+
83
+ it 'returns :peer for prestige in 0.4...0.6' do
84
+ model.prestige = 0.5
85
+ expect(model.prestige_label).to eq(:peer)
86
+ end
87
+
88
+ it 'returns :novice for prestige in 0.2...0.4' do
89
+ model.prestige = 0.3
90
+ expect(model.prestige_label).to eq(:novice)
91
+ end
92
+
93
+ it 'returns :unreliable for prestige below 0.2' do
94
+ model.prestige = 0.1
95
+ expect(model.prestige_label).to eq(:unreliable)
96
+ end
97
+ end
98
+
99
+ describe '#success_rate' do
100
+ it 'returns 0.0 with no observations' do
101
+ expect(model.success_rate).to eq(0.0)
102
+ end
103
+
104
+ it 'returns correct ratio after observations' do
105
+ fake_behavior = double('ObservedBehavior')
106
+ model.observe!(behavior: fake_behavior, outcome: :positive)
107
+ model.observe!(behavior: fake_behavior, outcome: :negative)
108
+ expect(model.success_rate).to eq(0.5)
109
+ end
110
+ end
111
+
112
+ describe '#to_h' do
113
+ it 'returns a hash with expected keys' do
114
+ hash = model.to_h
115
+ expect(hash).to include(:id, :agent_id, :domain, :prestige, :prestige_label,
116
+ :observation_count, :success_count, :success_rate,
117
+ :behavior_count, :created_at)
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::SocialLearning::Helpers::ObservedBehavior do
4
+ subject(:behavior) do
5
+ described_class.new(
6
+ model_agent_id: 'model-123',
7
+ action: 'write_test',
8
+ domain: :coding,
9
+ outcome: :positive,
10
+ context: { language: 'ruby' }
11
+ )
12
+ end
13
+
14
+ describe '#initialize' do
15
+ it 'assigns a uuid id' do
16
+ expect(behavior.id).to match(/\A[0-9a-f-]{36}\z/)
17
+ end
18
+
19
+ it 'sets retention to 1.0' do
20
+ expect(behavior.retention).to eq(1.0)
21
+ end
22
+
23
+ it 'sets reproduced to false' do
24
+ expect(behavior.reproduced).to be false
25
+ end
26
+
27
+ it 'stores the action' do
28
+ expect(behavior.action).to eq('write_test')
29
+ end
30
+
31
+ it 'stores the domain' do
32
+ expect(behavior.domain).to eq(:coding)
33
+ end
34
+
35
+ it 'stores the outcome' do
36
+ expect(behavior.outcome).to eq(:positive)
37
+ end
38
+
39
+ it 'stores context' do
40
+ expect(behavior.context).to eq({ language: 'ruby' })
41
+ end
42
+ end
43
+
44
+ describe '#decay_retention!' do
45
+ it 'reduces retention by RETENTION_DECAY' do
46
+ before = behavior.retention
47
+ behavior.decay_retention!
48
+ expect(behavior.retention).to be < before
49
+ end
50
+
51
+ it 'does not go below 0.0' do
52
+ 50.times { behavior.decay_retention! }
53
+ expect(behavior.retention).to be >= 0.0
54
+ end
55
+ end
56
+
57
+ describe '#retained?' do
58
+ it 'returns true when retention is above REPRODUCTION_CONFIDENCE' do
59
+ expect(behavior.retained?).to be true
60
+ end
61
+
62
+ it 'returns false when retention has decayed below threshold' do
63
+ behavior.retention = 0.4
64
+ expect(behavior.retained?).to be false
65
+ end
66
+ end
67
+
68
+ describe '#to_h' do
69
+ it 'returns a hash with expected keys' do
70
+ hash = behavior.to_h
71
+ expect(hash).to include(:id, :model_agent_id, :action, :domain, :outcome,
72
+ :retention, :reproduced, :retained, :created_at)
73
+ end
74
+
75
+ it 'rounds retention to 4 decimal places' do
76
+ behavior.decay_retention!
77
+ hash = behavior.to_h
78
+ expect(hash[:retention].to_s.split('.').last.length).to be <= 4
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::SocialLearning::Helpers::SocialLearningEngine do
4
+ subject(:engine) { described_class.new }
5
+
6
+ let(:model) { engine.register_model(agent_id: 'agent-1', domain: :coding) }
7
+
8
+ describe '#register_model' do
9
+ it 'returns a ModelAgent' do
10
+ expect(model).to be_a(Legion::Extensions::SocialLearning::Helpers::ModelAgent)
11
+ end
12
+
13
+ it 'assigns agent_id and domain' do
14
+ expect(model.agent_id).to eq('agent-1')
15
+ expect(model.domain).to eq(:coding)
16
+ end
17
+
18
+ it 'uses DEFAULT_PRESTIGE when none given' do
19
+ expect(model.prestige).to eq(Legion::Extensions::SocialLearning::Helpers::Constants::DEFAULT_PRESTIGE)
20
+ end
21
+ end
22
+
23
+ describe '#observe_behavior' do
24
+ it 'returns nil for unknown model' do
25
+ result = engine.observe_behavior(
26
+ model_id: 'nonexistent', action: 'do_thing', domain: :coding, outcome: :positive
27
+ )
28
+ expect(result).to be_nil
29
+ end
30
+
31
+ it 'returns nil when model prestige is below ATTENTION_THRESHOLD' do
32
+ low_model = engine.register_model(agent_id: 'low', domain: :coding, prestige: 0.1)
33
+ result = engine.observe_behavior(
34
+ model_id: low_model.id, action: 'do_thing', domain: :coding, outcome: :positive
35
+ )
36
+ expect(result).to be_nil
37
+ end
38
+
39
+ it 'returns an ObservedBehavior when model passes attention threshold' do
40
+ behavior = engine.observe_behavior(
41
+ model_id: model.id, action: 'write_test', domain: :coding, outcome: :positive
42
+ )
43
+ expect(behavior).to be_a(Legion::Extensions::SocialLearning::Helpers::ObservedBehavior)
44
+ end
45
+
46
+ it 'updates model prestige on positive outcome' do
47
+ before = model.prestige
48
+ engine.observe_behavior(
49
+ model_id: model.id, action: 'write_test', domain: :coding, outcome: :positive
50
+ )
51
+ expect(model.prestige).to be > before
52
+ end
53
+ end
54
+
55
+ describe '#retained_behaviors' do
56
+ before do
57
+ engine.observe_behavior(
58
+ model_id: model.id, action: 'action_a', domain: :coding, outcome: :positive
59
+ )
60
+ end
61
+
62
+ it 'returns behaviors with retention above REPRODUCTION_CONFIDENCE' do
63
+ result = engine.retained_behaviors
64
+ expect(result).not_to be_empty
65
+ end
66
+
67
+ it 'filters by domain when given' do
68
+ engine.observe_behavior(
69
+ model_id: model.id, action: 'action_b', domain: :ops, outcome: :positive
70
+ )
71
+ result = engine.retained_behaviors(domain: :coding)
72
+ expect(result.all? { |beh| beh.domain == :coding }).to be true
73
+ end
74
+ end
75
+
76
+ describe '#reproducible_behaviors' do
77
+ it 'returns behaviors above REPRODUCTION_CONFIDENCE' do
78
+ engine.observe_behavior(
79
+ model_id: model.id, action: 'write_test', domain: :coding, outcome: :positive
80
+ )
81
+ result = engine.reproducible_behaviors
82
+ expect(result).not_to be_empty
83
+ end
84
+ end
85
+
86
+ describe '#reproduce_behavior' do
87
+ let(:behavior) do
88
+ engine.observe_behavior(
89
+ model_id: model.id, action: 'write_test', domain: :coding, outcome: :positive
90
+ )
91
+ end
92
+
93
+ it 'marks the behavior as reproduced' do
94
+ engine.reproduce_behavior(behavior_id: behavior.id)
95
+ expect(behavior.reproduced).to be true
96
+ end
97
+
98
+ it 'returns nil for unknown behavior_id' do
99
+ result = engine.reproduce_behavior(behavior_id: 'nonexistent')
100
+ expect(result).to be_nil
101
+ end
102
+ end
103
+
104
+ describe '#reinforce_reproduction' do
105
+ let(:behavior) do
106
+ engine.observe_behavior(
107
+ model_id: model.id, action: 'write_test', domain: :coding, outcome: :positive
108
+ )
109
+ end
110
+
111
+ it 'boosts model prestige on positive outcome' do
112
+ before = model.prestige
113
+ engine.reinforce_reproduction(behavior_id: behavior.id, outcome: :positive)
114
+ expect(model.prestige).to be > before
115
+ end
116
+
117
+ it 'penalizes model prestige on negative outcome' do
118
+ before = model.prestige
119
+ engine.reinforce_reproduction(behavior_id: behavior.id, outcome: :negative)
120
+ expect(model.prestige).to be < before
121
+ end
122
+
123
+ it 'returns nil for unknown behavior_id' do
124
+ result = engine.reinforce_reproduction(behavior_id: 'nonexistent', outcome: :positive)
125
+ expect(result).to be_nil
126
+ end
127
+
128
+ it 'returns a hash with behavior and model_prestige' do
129
+ result = engine.reinforce_reproduction(behavior_id: behavior.id, outcome: :positive)
130
+ expect(result).to include(:behavior, :model_prestige)
131
+ end
132
+ end
133
+
134
+ describe '#best_models' do
135
+ before do
136
+ engine.register_model(agent_id: 'agent-2', domain: :ops, prestige: 0.9)
137
+ engine.register_model(agent_id: 'agent-3', domain: :coding, prestige: 0.3)
138
+ end
139
+
140
+ it 'returns models sorted by prestige descending' do
141
+ models = engine.best_models(limit: 3)
142
+ prestiges = models.map(&:prestige)
143
+ expect(prestiges).to eq(prestiges.sort.reverse)
144
+ end
145
+
146
+ it 'respects the limit' do
147
+ models = engine.best_models(limit: 2)
148
+ expect(models.size).to be <= 2
149
+ end
150
+ end
151
+
152
+ describe '#by_domain' do
153
+ before do
154
+ engine.register_model(agent_id: 'agent-ops', domain: :ops)
155
+ end
156
+
157
+ it 'returns only models in the given domain' do
158
+ result = engine.by_domain(domain: :ops)
159
+ expect(result.all? { |mod| mod.domain == :ops }).to be true
160
+ end
161
+
162
+ it 'returns empty array for unknown domain' do
163
+ result = engine.by_domain(domain: :unknown_domain)
164
+ expect(result).to be_empty
165
+ end
166
+ end
167
+
168
+ describe '#decay_all' do
169
+ it 'decays retention on all behaviors' do
170
+ behavior = engine.observe_behavior(
171
+ model_id: model.id, action: 'write_test', domain: :coding, outcome: :positive
172
+ )
173
+ before = behavior.retention
174
+ engine.decay_all
175
+ expect(behavior.retention).to be < before
176
+ end
177
+ end
178
+
179
+ describe '#prune_forgotten' do
180
+ it 'removes behaviors below 0.05 retention' do
181
+ behavior = engine.observe_behavior(
182
+ model_id: model.id, action: 'write_test', domain: :coding, outcome: :positive
183
+ )
184
+ behavior.retention = 0.04
185
+ engine.prune_forgotten
186
+ expect(engine.retained_behaviors).not_to include(behavior)
187
+ end
188
+ end
189
+
190
+ describe '#to_h' do
191
+ it 'returns stats hash' do
192
+ result = engine.to_h
193
+ expect(result).to include(:model_count, :behavior_count, :retained_count, :reproducible_count)
194
+ end
195
+ end
196
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::SocialLearning::Runners::SocialLearning do
4
+ let(:client) { Legion::Extensions::SocialLearning::Client.new }
5
+
6
+ describe '#register_model_agent' do
7
+ it 'returns success with model hash' do
8
+ result = client.register_model_agent(agent_id: 'agent-1', domain: :coding)
9
+ expect(result[:success]).to be true
10
+ expect(result[:model]).to include(:id, :agent_id, :domain, :prestige)
11
+ end
12
+
13
+ it 'accepts custom prestige' do
14
+ result = client.register_model_agent(agent_id: 'agent-1', domain: :coding, prestige: 0.8)
15
+ expect(result[:model][:prestige]).to eq(0.8)
16
+ end
17
+ end
18
+
19
+ describe '#observe_agent_behavior' do
20
+ let(:model_id) do
21
+ client.register_model_agent(agent_id: 'agent-1', domain: :coding)[:model][:id]
22
+ end
23
+
24
+ it 'returns success with behavior hash when model passes attention threshold' do
25
+ result = client.observe_agent_behavior(
26
+ model_id: model_id, action: 'write_test', domain: :coding, outcome: :positive
27
+ )
28
+ expect(result[:success]).to be true
29
+ expect(result[:behavior]).to include(:id, :action, :retention)
30
+ end
31
+
32
+ it 'returns success: false for unknown model_id' do
33
+ result = client.observe_agent_behavior(
34
+ model_id: 'nonexistent', action: 'do_thing', domain: :coding, outcome: :positive
35
+ )
36
+ expect(result[:success]).to be false
37
+ end
38
+ end
39
+
40
+ describe '#retained_behaviors' do
41
+ before do
42
+ model_id = client.register_model_agent(agent_id: 'agent-1', domain: :coding)[:model][:id]
43
+ client.observe_agent_behavior(
44
+ model_id: model_id, action: 'write_test', domain: :coding, outcome: :positive
45
+ )
46
+ end
47
+
48
+ it 'returns behaviors with count' do
49
+ result = client.retained_behaviors
50
+ expect(result[:success]).to be true
51
+ expect(result).to include(:behaviors, :count)
52
+ end
53
+
54
+ it 'filters by domain' do
55
+ result = client.retained_behaviors(domain: :coding)
56
+ expect(result[:behaviors].all? { |beh| beh[:domain] == :coding }).to be true
57
+ end
58
+ end
59
+
60
+ describe '#reproducible_behaviors' do
61
+ it 'returns success: true with behaviors list' do
62
+ result = client.reproducible_behaviors
63
+ expect(result[:success]).to be true
64
+ expect(result).to include(:behaviors, :count)
65
+ end
66
+ end
67
+
68
+ describe '#reproduce_observed_behavior' do
69
+ let(:behavior_id) do
70
+ model_id = client.register_model_agent(agent_id: 'agent-1', domain: :coding)[:model][:id]
71
+ client.observe_agent_behavior(
72
+ model_id: model_id, action: 'write_test', domain: :coding, outcome: :positive
73
+ )[:behavior][:id]
74
+ end
75
+
76
+ it 'returns success with reproduced behavior' do
77
+ result = client.reproduce_observed_behavior(behavior_id: behavior_id)
78
+ expect(result[:success]).to be true
79
+ expect(result[:behavior][:reproduced]).to be true
80
+ end
81
+
82
+ it 'returns success: false for unknown behavior_id' do
83
+ result = client.reproduce_observed_behavior(behavior_id: 'nonexistent')
84
+ expect(result[:success]).to be false
85
+ end
86
+ end
87
+
88
+ describe '#reinforce_reproduction' do
89
+ let(:behavior_id) do
90
+ model_id = client.register_model_agent(agent_id: 'agent-1', domain: :coding)[:model][:id]
91
+ client.observe_agent_behavior(
92
+ model_id: model_id, action: 'write_test', domain: :coding, outcome: :positive
93
+ )[:behavior][:id]
94
+ end
95
+
96
+ it 'returns success with model_prestige on positive outcome' do
97
+ result = client.reinforce_reproduction(behavior_id: behavior_id, outcome: :positive)
98
+ expect(result[:success]).to be true
99
+ expect(result[:model_prestige]).to be_a(Float)
100
+ end
101
+
102
+ it 'returns success: false for unknown behavior_id' do
103
+ result = client.reinforce_reproduction(behavior_id: 'nonexistent', outcome: :positive)
104
+ expect(result[:success]).to be false
105
+ end
106
+ end
107
+
108
+ describe '#best_model_agents' do
109
+ before do
110
+ client.register_model_agent(agent_id: 'agent-1', domain: :coding, prestige: 0.9)
111
+ client.register_model_agent(agent_id: 'agent-2', domain: :ops, prestige: 0.4)
112
+ end
113
+
114
+ it 'returns models sorted by prestige' do
115
+ result = client.best_model_agents(limit: 2)
116
+ expect(result[:success]).to be true
117
+ prestiges = result[:models].map { |mod| mod[:prestige] }
118
+ expect(prestiges).to eq(prestiges.sort.reverse)
119
+ end
120
+ end
121
+
122
+ describe '#domain_models' do
123
+ before do
124
+ client.register_model_agent(agent_id: 'agent-1', domain: :coding)
125
+ client.register_model_agent(agent_id: 'agent-2', domain: :ops)
126
+ end
127
+
128
+ it 'returns only models for the given domain' do
129
+ result = client.domain_models(domain: :coding)
130
+ expect(result[:success]).to be true
131
+ expect(result[:models].all? { |mod| mod[:domain] == :coding }).to be true
132
+ end
133
+ end
134
+
135
+ describe '#update_social_learning' do
136
+ it 'returns success with stats' do
137
+ result = client.update_social_learning
138
+ expect(result[:success]).to be true
139
+ expect(result).to include(:model_count, :behavior_count)
140
+ end
141
+ end
142
+
143
+ describe '#social_learning_stats' do
144
+ it 'returns success with stats hash' do
145
+ result = client.social_learning_stats
146
+ expect(result[:success]).to be true
147
+ expect(result).to include(:model_count, :behavior_count, :retained_count, :reproducible_count)
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/setup'
4
+
5
+ module Legion
6
+ module Logging
7
+ def self.debug(_msg); end
8
+ def self.info(_msg); end
9
+ def self.warn(_msg); end
10
+ def self.error(_msg); end
11
+ end
12
+ end
13
+
14
+ require 'legion/extensions/social_learning'
15
+
16
+ RSpec.configure do |config|
17
+ config.example_status_persistence_file_path = '.rspec_status'
18
+ config.disable_monkey_patching!
19
+ config.expect_with(:rspec) { |c| c.syntax = :expect }
20
+ end
metadata ADDED
@@ -0,0 +1,78 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: lex-social-learning
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Esity
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: legion-gaia
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :development
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
26
+ description: 'Bandura''s Social Cognitive Theory for LegionIO: vicarious learning
27
+ via model observation and reproduction'
28
+ email:
29
+ - matthewdiverson@gmail.com
30
+ executables: []
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - Gemfile
35
+ - lex-social-learning.gemspec
36
+ - lib/legion/extensions/social_learning.rb
37
+ - lib/legion/extensions/social_learning/client.rb
38
+ - lib/legion/extensions/social_learning/helpers/constants.rb
39
+ - lib/legion/extensions/social_learning/helpers/model_agent.rb
40
+ - lib/legion/extensions/social_learning/helpers/observed_behavior.rb
41
+ - lib/legion/extensions/social_learning/helpers/social_learning_engine.rb
42
+ - lib/legion/extensions/social_learning/runners/social_learning.rb
43
+ - lib/legion/extensions/social_learning/version.rb
44
+ - spec/legion/extensions/social_learning/client_spec.rb
45
+ - spec/legion/extensions/social_learning/helpers/constants_spec.rb
46
+ - spec/legion/extensions/social_learning/helpers/model_agent_spec.rb
47
+ - spec/legion/extensions/social_learning/helpers/observed_behavior_spec.rb
48
+ - spec/legion/extensions/social_learning/helpers/social_learning_engine_spec.rb
49
+ - spec/legion/extensions/social_learning/runners/social_learning_spec.rb
50
+ - spec/spec_helper.rb
51
+ homepage: https://github.com/LegionIO/lex-social-learning
52
+ licenses:
53
+ - MIT
54
+ metadata:
55
+ homepage_uri: https://github.com/LegionIO/lex-social-learning
56
+ source_code_uri: https://github.com/LegionIO/lex-social-learning
57
+ documentation_uri: https://github.com/LegionIO/lex-social-learning
58
+ changelog_uri: https://github.com/LegionIO/lex-social-learning
59
+ bug_tracker_uri: https://github.com/LegionIO/lex-social-learning/issues
60
+ rubygems_mfa_required: 'true'
61
+ rdoc_options: []
62
+ require_paths:
63
+ - lib
64
+ required_ruby_version: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '3.4'
69
+ required_rubygems_version: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - ">="
72
+ - !ruby/object:Gem::Version
73
+ version: '0'
74
+ requirements: []
75
+ rubygems_version: 3.6.9
76
+ specification_version: 4
77
+ summary: LEX Social Learning
78
+ test_files: []