lex-empathy 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: 40db9aaaa8d96a3ba285d18ccad9acc7fafd91182c50c924b2bfa2c245e861bb
4
+ data.tar.gz: d70d356a8d62939a8ed5614956535991670ab0cde5b958c48e9db575135fcb34
5
+ SHA512:
6
+ metadata.gz: 34685cb1524b649c3dde6115662ba3df9ee778772a648925b91463a7c4961139d5e2d0cea6f8663b6153a2603400d49ecbb9ab45974c356229a6430e129d9777
7
+ data.tar.gz: e45412bab0f4003356c941253953f874f77b41eb247248b87d1cb1b4dd17f217ba2e1277f05c31cee0d34f4756ae7ce77329b19e5152b0c674d3f1786f9ada93
data/Gemfile ADDED
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ gemspec
6
+
7
+ group :dev, :test do
8
+ gem 'rspec', '~> 3.12'
9
+ gem 'rubocop', '~> 1.75'
10
+ gem 'rubocop-rspec', '~> 3.0'
11
+ end
12
+
13
+ gem 'legion-gaia', path: '../../legion-gaia'
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Matthew Iverson
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,62 @@
1
+ # lex-empathy
2
+
3
+ Theory of Mind engine for the LegionIO brain-modeled cognitive architecture.
4
+
5
+ ## What It Does
6
+
7
+ Models other agents' mental states — their beliefs, emotions, intentions, and cooperation stance. Enables prediction of how other agents will react to proposed actions, perspective-taking via narrative generation, and social climate assessment across the mesh. Updates mental models from observed behavior using exponential moving averages, so the model continuously improves as more observations arrive.
8
+
9
+ ## Usage
10
+
11
+ ```ruby
12
+ client = Legion::Extensions::Empathy::Client.new
13
+
14
+ # Observe another agent's behavior to build a mental model
15
+ client.observe_agent(
16
+ agent_id: 'agent-b',
17
+ observation: {
18
+ goal: 'code_review',
19
+ emotion: :focused,
20
+ cooperation: :cooperative,
21
+ evidence_strength: 0.8
22
+ }
23
+ )
24
+
25
+ # Predict how they'll react to a proposed action
26
+ client.predict_reaction(
27
+ agent_id: 'agent-b',
28
+ scenario: { emotional_impact: :positive, impact_on_agent: :beneficial }
29
+ )
30
+ # => { likely_response: :likely_agree, confidence: 0.65, reasoning: '...' }
31
+
32
+ # Generate a perspective-taking narrative
33
+ client.perspective_take(agent_id: 'agent-b')
34
+ # => { narrative: "Agent agent-b appears to be pursuing code_review and seems focused...",
35
+ # model_confidence: 0.72 }
36
+
37
+ # Survey cooperation across all tracked agents
38
+ client.social_landscape
39
+ # => { tracked_agents: 5, cooperative_count: 3, overall_climate: :harmonious, climate_label: :harmonious }
40
+
41
+ # Track prediction accuracy over time
42
+ client.update_prediction_accuracy(
43
+ agent_id: 'agent-b',
44
+ predicted: :likely_agree,
45
+ actual: :likely_agree
46
+ )
47
+
48
+ # Periodic maintenance: decay stale models
49
+ client.update_empathy
50
+ ```
51
+
52
+ ## Development
53
+
54
+ ```bash
55
+ bundle install
56
+ bundle exec rspec
57
+ bundle exec rubocop
58
+ ```
59
+
60
+ ## License
61
+
62
+ MIT
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'lib/legion/extensions/empathy/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'lex-empathy'
7
+ spec.version = Legion::Extensions::Empathy::VERSION
8
+ spec.authors = ['Esity']
9
+ spec.email = ['matthewdiverson@gmail.com']
10
+
11
+ spec.summary = 'LEX Empathy'
12
+ spec.description = 'Theory of mind engine — models other agents beliefs, emotions, intentions, and cooperation stance'
13
+ spec.homepage = 'https://github.com/LegionIO/lex-empathy'
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-empathy'
19
+ spec.metadata['documentation_uri'] = 'https://github.com/LegionIO/lex-empathy'
20
+ spec.metadata['changelog_uri'] = 'https://github.com/LegionIO/lex-empathy'
21
+ spec.metadata['bug_tracker_uri'] = 'https://github.com/LegionIO/lex-empathy/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-empathy.gemspec Gemfile LICENSE README.md]
26
+ end
27
+ spec.require_paths = ['lib']
28
+ spec.add_development_dependency 'legion-gaia'
29
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Empathy
6
+ class Client
7
+ include Runners::Empathy
8
+
9
+ attr_reader :model_store
10
+
11
+ def initialize(model_store: nil, **)
12
+ @model_store = model_store || Helpers::ModelStore.new
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Empathy
6
+ module Helpers
7
+ module Constants
8
+ # Mental state dimensions tracked per agent
9
+ MENTAL_STATE_DIMENSIONS = %i[
10
+ believed_goal
11
+ emotional_state
12
+ attention_focus
13
+ confidence_level
14
+ cooperation_stance
15
+ ].freeze
16
+
17
+ # How quickly mental models update (EMA alpha)
18
+ MODEL_UPDATE_ALPHA = 0.2
19
+
20
+ # How long before a mental model is considered stale (seconds)
21
+ MODEL_STALENESS_THRESHOLD = 300
22
+
23
+ # Maximum number of tracked agents
24
+ MAX_TRACKED_AGENTS = 100
25
+
26
+ # Maximum interaction history per agent
27
+ MAX_INTERACTION_HISTORY = 50
28
+
29
+ # Prediction confidence thresholds
30
+ PREDICTION_CONFIDENT = 0.7
31
+ PREDICTION_UNCERTAIN = 0.4
32
+
33
+ # Cooperation stance values
34
+ COOPERATION_STANCES = %i[cooperative neutral competitive unknown].freeze
35
+
36
+ # Emotional state labels for other agents
37
+ INFERRED_EMOTIONS = %i[
38
+ calm focused stressed frustrated curious cautious enthusiastic unknown
39
+ ].freeze
40
+
41
+ # Perspective-taking accuracy tracking window
42
+ ACCURACY_WINDOW = 20
43
+
44
+ # Mental model decay rate (per decay cycle)
45
+ MODEL_DECAY_RATE = 0.01
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Empathy
6
+ module Helpers
7
+ class MentalModel
8
+ attr_reader :agent_id, :believed_goal, :emotional_state, :attention_focus,
9
+ :confidence_level, :cooperation_stance, :interaction_history,
10
+ :predictions, :created_at, :updated_at
11
+
12
+ def initialize(agent_id:)
13
+ @agent_id = agent_id
14
+ @believed_goal = nil
15
+ @emotional_state = :unknown
16
+ @attention_focus = nil
17
+ @confidence_level = 0.5
18
+ @cooperation_stance = :unknown
19
+ @interaction_history = []
20
+ @predictions = []
21
+ @prediction_outcomes = []
22
+ @created_at = Time.now.utc
23
+ @updated_at = @created_at
24
+ end
25
+
26
+ def update_from_observation(observation)
27
+ @updated_at = Time.now.utc
28
+
29
+ update_believed_goal(observation[:goal]) if observation[:goal]
30
+ update_emotional_state(observation[:emotion]) if observation[:emotion]
31
+ update_attention(observation[:attention]) if observation[:attention]
32
+ update_cooperation(observation[:cooperation]) if observation[:cooperation]
33
+ update_confidence(observation)
34
+
35
+ record_interaction(observation)
36
+ end
37
+
38
+ def predict_reaction(scenario)
39
+ prediction = {
40
+ prediction_id: SecureRandom.uuid,
41
+ scenario: scenario,
42
+ predicted_at: Time.now.utc,
43
+ likely_response: infer_response(scenario),
44
+ emotional_shift: infer_emotional_shift(scenario),
45
+ cooperation_shift: infer_cooperation_shift(scenario),
46
+ confidence: prediction_confidence
47
+ }
48
+
49
+ @predictions << prediction
50
+ @predictions = @predictions.last(Constants::MAX_INTERACTION_HISTORY)
51
+ prediction
52
+ end
53
+
54
+ def record_prediction_outcome(prediction_id:, actual_response:, accurate:)
55
+ pred = @predictions.find { |p| p[:prediction_id] == prediction_id }
56
+ return nil unless pred
57
+
58
+ pred[:actual_response] = actual_response
59
+ pred[:accurate] = accurate
60
+
61
+ @prediction_outcomes << { prediction_id: prediction_id, accurate: accurate, at: Time.now.utc }
62
+ @prediction_outcomes = @prediction_outcomes.last(Constants::ACCURACY_WINDOW)
63
+ accurate
64
+ end
65
+
66
+ def prediction_accuracy
67
+ return nil if @prediction_outcomes.empty?
68
+
69
+ correct = @prediction_outcomes.count { |o| o[:accurate] }
70
+ correct.to_f / @prediction_outcomes.size
71
+ end
72
+
73
+ def stale?
74
+ (Time.now.utc - @updated_at) > Constants::MODEL_STALENESS_THRESHOLD
75
+ end
76
+
77
+ def decay
78
+ @confidence_level = [(@confidence_level - Constants::MODEL_DECAY_RATE), 0.1].max
79
+ end
80
+
81
+ def to_h
82
+ {
83
+ agent_id: @agent_id,
84
+ believed_goal: @believed_goal,
85
+ emotional_state: @emotional_state,
86
+ attention_focus: @attention_focus,
87
+ confidence_level: @confidence_level,
88
+ cooperation_stance: @cooperation_stance,
89
+ interactions: @interaction_history.size,
90
+ predictions_made: @predictions.size,
91
+ prediction_accuracy: prediction_accuracy,
92
+ stale: stale?,
93
+ created_at: @created_at,
94
+ updated_at: @updated_at
95
+ }
96
+ end
97
+
98
+ private
99
+
100
+ def update_believed_goal(goal)
101
+ @believed_goal = goal
102
+ end
103
+
104
+ def update_emotional_state(emotion)
105
+ sym = emotion.to_sym
106
+ @emotional_state = Constants::INFERRED_EMOTIONS.include?(sym) ? sym : :unknown
107
+ end
108
+
109
+ def update_attention(attention)
110
+ @attention_focus = attention
111
+ end
112
+
113
+ def update_cooperation(cooperation)
114
+ sym = cooperation.to_sym
115
+ @cooperation_stance = Constants::COOPERATION_STANCES.include?(sym) ? sym : :unknown
116
+ end
117
+
118
+ def update_confidence(observation)
119
+ evidence_strength = observation[:evidence_strength] || 0.5
120
+ alpha = Constants::MODEL_UPDATE_ALPHA
121
+ @confidence_level = (@confidence_level * (1 - alpha)) + (evidence_strength * alpha)
122
+ @confidence_level = @confidence_level.clamp(0.0, 1.0)
123
+ end
124
+
125
+ def record_interaction(observation)
126
+ @interaction_history << {
127
+ type: observation[:interaction_type] || :observation,
128
+ summary: observation[:summary],
129
+ at: Time.now.utc
130
+ }
131
+ @interaction_history = @interaction_history.last(Constants::MAX_INTERACTION_HISTORY)
132
+ end
133
+
134
+ def infer_response(scenario)
135
+ case @cooperation_stance
136
+ when :cooperative
137
+ scenario[:cooperative_option] || :likely_agree
138
+ when :competitive
139
+ scenario[:competitive_option] || :likely_resist
140
+ when :neutral
141
+ @confidence_level > 0.5 ? :likely_consider : :unpredictable
142
+ else
143
+ :unpredictable
144
+ end
145
+ end
146
+
147
+ def infer_emotional_shift(scenario)
148
+ impact = scenario[:emotional_impact] || :neutral
149
+ case impact
150
+ when :positive
151
+ :likely_positive
152
+ when :negative
153
+ stressed_states = %i[stressed frustrated cautious]
154
+ stressed_states.include?(@emotional_state) ? :likely_escalate : :likely_negative
155
+ else
156
+ :likely_stable
157
+ end
158
+ end
159
+
160
+ def infer_cooperation_shift(scenario)
161
+ return :stable if scenario[:impact_on_agent].nil?
162
+
163
+ case scenario[:impact_on_agent]
164
+ when :beneficial then :likely_more_cooperative
165
+ when :harmful then :likely_less_cooperative
166
+ else :stable
167
+ end
168
+ end
169
+
170
+ def prediction_confidence
171
+ base = @confidence_level
172
+ staleness_penalty = stale? ? 0.2 : 0.0
173
+ history_bonus = [@interaction_history.size / 20.0, 0.2].min
174
+
175
+ (base - staleness_penalty + history_bonus).clamp(0.1, 0.9)
176
+ end
177
+ end
178
+ end
179
+ end
180
+ end
181
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Empathy
6
+ module Helpers
7
+ class ModelStore
8
+ attr_reader :models
9
+
10
+ def initialize
11
+ @models = {}
12
+ end
13
+
14
+ def get(agent_id)
15
+ @models[agent_id.to_s]
16
+ end
17
+
18
+ def get_or_create(agent_id)
19
+ key = agent_id.to_s
20
+ @models[key] ||= MentalModel.new(agent_id: key)
21
+ end
22
+
23
+ def update(agent_id, observation)
24
+ model = get_or_create(agent_id)
25
+ model.update_from_observation(observation)
26
+ evict_if_needed
27
+ model
28
+ end
29
+
30
+ def predict(agent_id, scenario)
31
+ model = get(agent_id)
32
+ return nil unless model
33
+
34
+ model.predict_reaction(scenario)
35
+ end
36
+
37
+ def decay_all
38
+ count = 0
39
+ @models.each_value do |model|
40
+ model.decay
41
+ count += 1
42
+ end
43
+ count
44
+ end
45
+
46
+ def remove_stale
47
+ stale_keys = @models.select { |_, m| m.stale? && m.interaction_history.empty? }.keys
48
+ stale_keys.each { |k| @models.delete(k) }
49
+ stale_keys.size
50
+ end
51
+
52
+ def all_models
53
+ @models.values
54
+ end
55
+
56
+ def by_cooperation(stance)
57
+ @models.values.select { |m| m.cooperation_stance == stance }
58
+ end
59
+
60
+ def by_emotion(emotion)
61
+ @models.values.select { |m| m.emotional_state == emotion }
62
+ end
63
+
64
+ def size
65
+ @models.size
66
+ end
67
+
68
+ def clear
69
+ @models.clear
70
+ end
71
+
72
+ private
73
+
74
+ def evict_if_needed
75
+ return unless @models.size > Constants::MAX_TRACKED_AGENTS
76
+
77
+ oldest = @models.min_by { |_, m| m.updated_at }
78
+ @models.delete(oldest[0]) if oldest
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Empathy
6
+ module Runners
7
+ module Empathy
8
+ include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers) &&
9
+ Legion::Extensions::Helpers.const_defined?(:Lex)
10
+
11
+ def observe_agent(agent_id:, observation: {}, **)
12
+ model = model_store.update(agent_id, observation)
13
+ Legion::Logging.debug "[empathy] observed: agent=#{agent_id} emotion=#{model.emotional_state} " \
14
+ "cooperation=#{model.cooperation_stance}"
15
+
16
+ {
17
+ agent_id: agent_id,
18
+ emotional_state: model.emotional_state,
19
+ cooperation_stance: model.cooperation_stance,
20
+ believed_goal: model.believed_goal,
21
+ confidence: model.confidence_level
22
+ }
23
+ end
24
+
25
+ def predict_reaction(agent_id:, scenario: {}, **)
26
+ prediction = model_store.predict(agent_id, scenario)
27
+
28
+ if prediction
29
+ Legion::Logging.debug "[empathy] prediction: agent=#{agent_id} response=#{prediction[:likely_response]} " \
30
+ "confidence=#{prediction[:confidence].round(2)}"
31
+ prediction
32
+ else
33
+ Legion::Logging.debug "[empathy] no model for agent=#{agent_id}"
34
+ { error: :no_model, agent_id: agent_id }
35
+ end
36
+ end
37
+
38
+ def record_outcome(agent_id:, prediction_id:, actual_response:, accurate:, **)
39
+ model = model_store.get(agent_id)
40
+ return { error: :no_model } unless model
41
+
42
+ result = model.record_prediction_outcome(
43
+ prediction_id: prediction_id,
44
+ actual_response: actual_response,
45
+ accurate: accurate
46
+ )
47
+
48
+ if result.nil?
49
+ { error: :prediction_not_found }
50
+ else
51
+ Legion::Logging.info "[empathy] outcome recorded: agent=#{agent_id} accurate=#{accurate} " \
52
+ "accuracy=#{model.prediction_accuracy&.round(2)}"
53
+ { agent_id: agent_id, accurate: accurate, current_accuracy: model.prediction_accuracy }
54
+ end
55
+ end
56
+
57
+ def perspective_take(agent_id:, **)
58
+ model = model_store.get(agent_id)
59
+ return { error: :no_model, agent_id: agent_id } unless model
60
+
61
+ narrative = build_perspective_narrative(model)
62
+ Legion::Logging.debug "[empathy] perspective: agent=#{agent_id}"
63
+
64
+ {
65
+ agent_id: agent_id,
66
+ narrative: narrative,
67
+ model: model.to_h
68
+ }
69
+ end
70
+
71
+ def social_landscape(**)
72
+ models = model_store.all_models
73
+ cooperative = model_store.by_cooperation(:cooperative).size
74
+ competitive = model_store.by_cooperation(:competitive).size
75
+ stressed = model_store.by_emotion(:stressed).size + model_store.by_emotion(:frustrated).size
76
+
77
+ Legion::Logging.debug "[empathy] landscape: agents=#{models.size} cooperative=#{cooperative} " \
78
+ "competitive=#{competitive} stressed=#{stressed}"
79
+
80
+ {
81
+ tracked_agents: models.size,
82
+ cooperative_count: cooperative,
83
+ competitive_count: competitive,
84
+ stressed_count: stressed,
85
+ stances: stance_distribution(models),
86
+ emotions: emotion_distribution(models),
87
+ overall_climate: assess_climate(cooperative, competitive, stressed, models.size)
88
+ }
89
+ end
90
+
91
+ def decay_models(**)
92
+ decayed = model_store.decay_all
93
+ removed = model_store.remove_stale
94
+ Legion::Logging.debug "[empathy] decay: updated=#{decayed} stale_removed=#{removed}"
95
+ { decayed: decayed, stale_removed: removed }
96
+ end
97
+
98
+ def empathy_stats(**)
99
+ models = model_store.all_models
100
+ accuracies = models.filter_map(&:prediction_accuracy)
101
+
102
+ {
103
+ tracked_agents: model_store.size,
104
+ total_predictions: models.sum { |m| m.predictions.size },
105
+ avg_accuracy: accuracies.empty? ? nil : (accuracies.sum / accuracies.size).round(3),
106
+ stale_models: models.count(&:stale?),
107
+ cooperation_stances: stance_distribution(models)
108
+ }
109
+ end
110
+
111
+ private
112
+
113
+ def model_store
114
+ @model_store ||= Helpers::ModelStore.new
115
+ end
116
+
117
+ def build_perspective_narrative(model)
118
+ parts = []
119
+ parts << "Agent #{model.agent_id}"
120
+
121
+ parts << if model.believed_goal
122
+ "appears to be pursuing #{model.believed_goal}"
123
+ else
124
+ 'has no clearly observed goal'
125
+ end
126
+
127
+ parts << "and seems #{model.emotional_state}" unless model.emotional_state == :unknown
128
+ parts << "with a #{model.cooperation_stance} stance" unless model.cooperation_stance == :unknown
129
+
130
+ parts << "(model is stale — last updated #{((Time.now.utc - model.updated_at) / 60).round(1)} minutes ago)" if model.stale?
131
+
132
+ parts << "— prediction accuracy: #{(model.prediction_accuracy * 100).round(1)}%" if model.prediction_accuracy
133
+
134
+ parts.join(' ')
135
+ end
136
+
137
+ def stance_distribution(models)
138
+ dist = Hash.new(0)
139
+ models.each { |m| dist[m.cooperation_stance] += 1 }
140
+ dist
141
+ end
142
+
143
+ def emotion_distribution(models)
144
+ dist = Hash.new(0)
145
+ models.each { |m| dist[m.emotional_state] += 1 }
146
+ dist
147
+ end
148
+
149
+ def assess_climate(cooperative, competitive, stressed, total)
150
+ return :empty if total.zero?
151
+
152
+ coop_ratio = cooperative.to_f / total
153
+ stress_ratio = stressed.to_f / total
154
+
155
+ if coop_ratio > 0.6
156
+ :harmonious
157
+ elsif stress_ratio > 0.4
158
+ :tense
159
+ elsif competitive > cooperative
160
+ :adversarial
161
+ else
162
+ :neutral
163
+ end
164
+ end
165
+ end
166
+ end
167
+ end
168
+ end
169
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Empathy
6
+ VERSION = '0.1.0'
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+ require 'legion/extensions/empathy/version'
5
+ require 'legion/extensions/empathy/helpers/constants'
6
+ require 'legion/extensions/empathy/helpers/mental_model'
7
+ require 'legion/extensions/empathy/helpers/model_store'
8
+ require 'legion/extensions/empathy/runners/empathy'
9
+ require 'legion/extensions/empathy/client'
10
+
11
+ module Legion
12
+ module Extensions
13
+ module Empathy
14
+ extend Legion::Extensions::Core if Legion::Extensions.const_defined?(:Core)
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::Empathy::Client do
4
+ it 'creates default model store' do
5
+ client = described_class.new
6
+ expect(client.model_store).to be_a(Legion::Extensions::Empathy::Helpers::ModelStore)
7
+ end
8
+
9
+ it 'accepts injected model store' do
10
+ store = Legion::Extensions::Empathy::Helpers::ModelStore.new
11
+ client = described_class.new(model_store: store)
12
+ expect(client.model_store).to equal(store)
13
+ end
14
+
15
+ it 'includes Empathy runner methods' do
16
+ client = described_class.new
17
+ expect(client).to respond_to(:observe_agent, :predict_reaction, :perspective_take,
18
+ :social_landscape, :empathy_stats)
19
+ end
20
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::Empathy::Helpers::Constants do
4
+ it 'defines 5 mental state dimensions' do
5
+ expect(described_class::MENTAL_STATE_DIMENSIONS.size).to eq(5)
6
+ end
7
+
8
+ it 'defines cooperation stances' do
9
+ expect(described_class::COOPERATION_STANCES).to include(:cooperative, :competitive, :neutral, :unknown)
10
+ end
11
+
12
+ it 'defines inferred emotions' do
13
+ expect(described_class::INFERRED_EMOTIONS).to include(:calm, :stressed, :curious, :unknown)
14
+ end
15
+
16
+ it 'defines prediction thresholds in order' do
17
+ expect(described_class::PREDICTION_UNCERTAIN).to be < described_class::PREDICTION_CONFIDENT
18
+ end
19
+
20
+ it 'sets MAX_TRACKED_AGENTS to 100' do
21
+ expect(described_class::MAX_TRACKED_AGENTS).to eq(100)
22
+ end
23
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::Empathy::Helpers::MentalModel do
4
+ subject(:model) { described_class.new(agent_id: 'agent-42') }
5
+
6
+ describe '#initialize' do
7
+ it 'sets agent_id' do
8
+ expect(model.agent_id).to eq('agent-42')
9
+ end
10
+
11
+ it 'starts with unknown emotional state' do
12
+ expect(model.emotional_state).to eq(:unknown)
13
+ end
14
+
15
+ it 'starts with unknown cooperation stance' do
16
+ expect(model.cooperation_stance).to eq(:unknown)
17
+ end
18
+
19
+ it 'starts with 0.5 confidence' do
20
+ expect(model.confidence_level).to eq(0.5)
21
+ end
22
+ end
23
+
24
+ describe '#update_from_observation' do
25
+ it 'updates believed goal' do
26
+ model.update_from_observation(goal: 'code_review')
27
+ expect(model.believed_goal).to eq('code_review')
28
+ end
29
+
30
+ it 'updates emotional state' do
31
+ model.update_from_observation(emotion: :focused)
32
+ expect(model.emotional_state).to eq(:focused)
33
+ end
34
+
35
+ it 'updates cooperation stance' do
36
+ model.update_from_observation(cooperation: :cooperative)
37
+ expect(model.cooperation_stance).to eq(:cooperative)
38
+ end
39
+
40
+ it 'records interaction history' do
41
+ model.update_from_observation(summary: 'sent a message')
42
+ expect(model.interaction_history.size).to eq(1)
43
+ end
44
+
45
+ it 'rejects unknown emotions as :unknown' do
46
+ model.update_from_observation(emotion: :nonexistent)
47
+ expect(model.emotional_state).to eq(:unknown)
48
+ end
49
+
50
+ it 'updates confidence via EMA' do
51
+ model.update_from_observation(evidence_strength: 0.9)
52
+ expect(model.confidence_level).to be > 0.5
53
+ end
54
+ end
55
+
56
+ describe '#predict_reaction' do
57
+ before do
58
+ model.update_from_observation(cooperation: :cooperative, emotion: :calm)
59
+ end
60
+
61
+ it 'returns a prediction hash' do
62
+ prediction = model.predict_reaction(emotional_impact: :positive)
63
+ expect(prediction).to have_key(:prediction_id)
64
+ expect(prediction).to have_key(:likely_response)
65
+ expect(prediction).to have_key(:confidence)
66
+ end
67
+
68
+ it 'predicts cooperative agents will likely agree' do
69
+ prediction = model.predict_reaction(cooperative_option: :accept)
70
+ expect(prediction[:likely_response]).to eq(:accept)
71
+ end
72
+
73
+ it 'stores predictions' do
74
+ model.predict_reaction({})
75
+ expect(model.predictions.size).to eq(1)
76
+ end
77
+ end
78
+
79
+ describe '#record_prediction_outcome' do
80
+ it 'records accurate prediction' do
81
+ prediction = model.predict_reaction({})
82
+ result = model.record_prediction_outcome(
83
+ prediction_id: prediction[:prediction_id],
84
+ actual_response: :agreed,
85
+ accurate: true
86
+ )
87
+ expect(result).to be true
88
+ end
89
+
90
+ it 'returns nil for unknown prediction' do
91
+ result = model.record_prediction_outcome(
92
+ prediction_id: 'nonexistent',
93
+ actual_response: :agreed,
94
+ accurate: true
95
+ )
96
+ expect(result).to be_nil
97
+ end
98
+ end
99
+
100
+ describe '#prediction_accuracy' do
101
+ it 'returns nil with no outcomes' do
102
+ expect(model.prediction_accuracy).to be_nil
103
+ end
104
+
105
+ it 'computes accuracy from outcomes' do
106
+ 3.times do
107
+ pred = model.predict_reaction({})
108
+ model.record_prediction_outcome(prediction_id: pred[:prediction_id],
109
+ actual_response: :ok, accurate: true)
110
+ end
111
+ pred = model.predict_reaction({})
112
+ model.record_prediction_outcome(prediction_id: pred[:prediction_id],
113
+ actual_response: :nope, accurate: false)
114
+
115
+ expect(model.prediction_accuracy).to eq(0.75)
116
+ end
117
+ end
118
+
119
+ describe '#stale?' do
120
+ it 'returns false when fresh' do
121
+ expect(model.stale?).to be false
122
+ end
123
+
124
+ it 'returns true when old' do
125
+ model.instance_variable_set(:@updated_at, Time.now.utc - 400)
126
+ expect(model.stale?).to be true
127
+ end
128
+ end
129
+
130
+ describe '#decay' do
131
+ it 'reduces confidence' do
132
+ original = model.confidence_level
133
+ model.decay
134
+ expect(model.confidence_level).to be < original
135
+ end
136
+
137
+ it 'floors confidence at 0.1' do
138
+ 50.times { model.decay }
139
+ expect(model.confidence_level).to be >= 0.1
140
+ end
141
+ end
142
+
143
+ describe '#to_h' do
144
+ it 'returns a complete state hash' do
145
+ h = model.to_h
146
+ expect(h).to include(:agent_id, :believed_goal, :emotional_state,
147
+ :cooperation_stance, :confidence_level, :stale)
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::Empathy::Helpers::ModelStore do
4
+ subject(:store) { described_class.new }
5
+
6
+ describe '#get_or_create' do
7
+ it 'creates a new model for unknown agent' do
8
+ model = store.get_or_create('agent-1')
9
+ expect(model.agent_id).to eq('agent-1')
10
+ end
11
+
12
+ it 'returns existing model' do
13
+ m1 = store.get_or_create('agent-1')
14
+ m2 = store.get_or_create('agent-1')
15
+ expect(m1).to equal(m2)
16
+ end
17
+ end
18
+
19
+ describe '#update' do
20
+ it 'updates model with observation' do
21
+ model = store.update('agent-1', emotion: :focused, cooperation: :cooperative)
22
+ expect(model.emotional_state).to eq(:focused)
23
+ end
24
+
25
+ it 'increments store size' do
26
+ store.update('agent-1', {})
27
+ store.update('agent-2', {})
28
+ expect(store.size).to eq(2)
29
+ end
30
+ end
31
+
32
+ describe '#predict' do
33
+ it 'returns nil for unknown agent' do
34
+ expect(store.predict('nobody', {})).to be_nil
35
+ end
36
+
37
+ it 'returns prediction for known agent' do
38
+ store.update('agent-1', cooperation: :cooperative)
39
+ prediction = store.predict('agent-1', {})
40
+ expect(prediction).to have_key(:likely_response)
41
+ end
42
+ end
43
+
44
+ describe '#decay_all' do
45
+ it 'decays all models' do
46
+ store.update('agent-1', {})
47
+ store.update('agent-2', {})
48
+ count = store.decay_all
49
+ expect(count).to eq(2)
50
+ end
51
+ end
52
+
53
+ describe '#remove_stale' do
54
+ it 'removes stale models with no interactions' do
55
+ store.get_or_create('agent-old')
56
+ store.models['agent-old'].instance_variable_set(:@updated_at, Time.now.utc - 400)
57
+ removed = store.remove_stale
58
+ expect(removed).to eq(1)
59
+ expect(store.size).to eq(0)
60
+ end
61
+
62
+ it 'keeps stale models that have interactions' do
63
+ store.update('agent-old', summary: 'did something')
64
+ store.models['agent-old'].instance_variable_set(:@updated_at, Time.now.utc - 400)
65
+ removed = store.remove_stale
66
+ expect(removed).to eq(0)
67
+ end
68
+ end
69
+
70
+ describe '#by_cooperation' do
71
+ it 'filters by cooperation stance' do
72
+ store.update('a', cooperation: :cooperative)
73
+ store.update('b', cooperation: :competitive)
74
+ store.update('c', cooperation: :cooperative)
75
+ expect(store.by_cooperation(:cooperative).size).to eq(2)
76
+ end
77
+ end
78
+
79
+ describe '#by_emotion' do
80
+ it 'filters by emotional state' do
81
+ store.update('a', emotion: :stressed)
82
+ store.update('b', emotion: :calm)
83
+ expect(store.by_emotion(:stressed).size).to eq(1)
84
+ end
85
+ end
86
+
87
+ describe '#clear' do
88
+ it 'removes all models' do
89
+ store.update('a', {})
90
+ store.clear
91
+ expect(store.size).to eq(0)
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::Empathy::Runners::Empathy do
4
+ let(:client) { Legion::Extensions::Empathy::Client.new }
5
+
6
+ describe '#observe_agent' do
7
+ it 'creates a mental model from observation' do
8
+ result = client.observe_agent(agent_id: 'agent-b', observation: {
9
+ goal: 'testing', emotion: :focused, cooperation: :cooperative, evidence_strength: 0.8
10
+ })
11
+ expect(result[:emotional_state]).to eq(:focused)
12
+ expect(result[:cooperation_stance]).to eq(:cooperative)
13
+ expect(result[:believed_goal]).to eq('testing')
14
+ end
15
+
16
+ it 'updates existing models' do
17
+ client.observe_agent(agent_id: 'agent-b', observation: { emotion: :calm })
18
+ result = client.observe_agent(agent_id: 'agent-b', observation: { emotion: :stressed })
19
+ expect(result[:emotional_state]).to eq(:stressed)
20
+ end
21
+ end
22
+
23
+ describe '#predict_reaction' do
24
+ before do
25
+ client.observe_agent(agent_id: 'agent-b', observation: {
26
+ cooperation: :cooperative, emotion: :calm
27
+ })
28
+ end
29
+
30
+ it 'returns prediction for known agent' do
31
+ result = client.predict_reaction(agent_id: 'agent-b', scenario: {
32
+ emotional_impact: :positive, impact_on_agent: :beneficial
33
+ })
34
+ expect(result).to have_key(:likely_response)
35
+ expect(result).to have_key(:confidence)
36
+ end
37
+
38
+ it 'returns error for unknown agent' do
39
+ result = client.predict_reaction(agent_id: 'nobody', scenario: {})
40
+ expect(result[:error]).to eq(:no_model)
41
+ end
42
+ end
43
+
44
+ describe '#record_outcome' do
45
+ it 'records prediction outcome' do
46
+ client.observe_agent(agent_id: 'agent-b', observation: { cooperation: :cooperative })
47
+ prediction = client.predict_reaction(agent_id: 'agent-b', scenario: {})
48
+ result = client.record_outcome(
49
+ agent_id: 'agent-b',
50
+ prediction_id: prediction[:prediction_id],
51
+ actual_response: :agreed,
52
+ accurate: true
53
+ )
54
+ expect(result[:accurate]).to be true
55
+ expect(result[:current_accuracy]).to eq(1.0)
56
+ end
57
+
58
+ it 'returns error for unknown agent' do
59
+ result = client.record_outcome(agent_id: 'nobody', prediction_id: 'x',
60
+ actual_response: :ok, accurate: true)
61
+ expect(result[:error]).to eq(:no_model)
62
+ end
63
+
64
+ it 'returns error for unknown prediction' do
65
+ client.observe_agent(agent_id: 'agent-b', observation: {})
66
+ result = client.record_outcome(agent_id: 'agent-b', prediction_id: 'nonexistent',
67
+ actual_response: :ok, accurate: true)
68
+ expect(result[:error]).to eq(:prediction_not_found)
69
+ end
70
+ end
71
+
72
+ describe '#perspective_take' do
73
+ it 'generates narrative for known agent' do
74
+ client.observe_agent(agent_id: 'agent-b', observation: {
75
+ goal: 'code_review', emotion: :focused, cooperation: :cooperative
76
+ })
77
+ result = client.perspective_take(agent_id: 'agent-b')
78
+ expect(result[:narrative]).to include('agent-b')
79
+ expect(result[:narrative]).to include('code_review')
80
+ end
81
+
82
+ it 'returns error for unknown agent' do
83
+ result = client.perspective_take(agent_id: 'nobody')
84
+ expect(result[:error]).to eq(:no_model)
85
+ end
86
+ end
87
+
88
+ describe '#social_landscape' do
89
+ before do
90
+ client.observe_agent(agent_id: 'a', observation: { cooperation: :cooperative, emotion: :calm })
91
+ client.observe_agent(agent_id: 'b', observation: { cooperation: :cooperative, emotion: :focused })
92
+ client.observe_agent(agent_id: 'c', observation: { cooperation: :competitive, emotion: :stressed })
93
+ end
94
+
95
+ it 'returns social climate assessment' do
96
+ result = client.social_landscape
97
+ expect(result[:tracked_agents]).to eq(3)
98
+ expect(result[:cooperative_count]).to eq(2)
99
+ expect(result[:competitive_count]).to eq(1)
100
+ expect(result).to have_key(:overall_climate)
101
+ end
102
+
103
+ it 'assesses harmonious climate when mostly cooperative' do
104
+ client.observe_agent(agent_id: 'd', observation: { cooperation: :cooperative })
105
+ result = client.social_landscape
106
+ expect(result[:overall_climate]).to eq(:harmonious)
107
+ end
108
+ end
109
+
110
+ describe '#decay_models' do
111
+ it 'decays all models' do
112
+ client.observe_agent(agent_id: 'a', observation: {})
113
+ result = client.decay_models
114
+ expect(result[:decayed]).to eq(1)
115
+ end
116
+ end
117
+
118
+ describe '#empathy_stats' do
119
+ it 'returns summary statistics' do
120
+ client.observe_agent(agent_id: 'a', observation: { cooperation: :cooperative })
121
+ client.predict_reaction(agent_id: 'a', scenario: {})
122
+ result = client.empathy_stats
123
+ expect(result[:tracked_agents]).to eq(1)
124
+ expect(result[:total_predictions]).to eq(1)
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Logging
5
+ module_function
6
+
7
+ def debug(*); end
8
+
9
+ def info(*); end
10
+
11
+ def warn(*); end
12
+
13
+ def error(*); end
14
+ end
15
+
16
+ module Extensions
17
+ module Helpers; end
18
+ end
19
+ end
20
+
21
+ require 'legion/extensions/empathy'
22
+
23
+ RSpec.configure do |config|
24
+ config.expect_with :rspec do |expectations|
25
+ expectations.include_chain_clauses_in_custom_matcher_descriptions = true
26
+ end
27
+
28
+ config.mock_with :rspec do |mocks|
29
+ mocks.verify_partial_doubles = true
30
+ end
31
+
32
+ config.shared_context_metadata_behavior = :apply_to_host_groups
33
+ config.order = :random
34
+ Kernel.srand config.seed
35
+ end
metadata ADDED
@@ -0,0 +1,78 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: lex-empathy
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: Theory of mind engine — models other agents beliefs, emotions, intentions,
27
+ and cooperation stance
28
+ email:
29
+ - matthewdiverson@gmail.com
30
+ executables: []
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - Gemfile
35
+ - LICENSE
36
+ - README.md
37
+ - lex-empathy.gemspec
38
+ - lib/legion/extensions/empathy.rb
39
+ - lib/legion/extensions/empathy/client.rb
40
+ - lib/legion/extensions/empathy/helpers/constants.rb
41
+ - lib/legion/extensions/empathy/helpers/mental_model.rb
42
+ - lib/legion/extensions/empathy/helpers/model_store.rb
43
+ - lib/legion/extensions/empathy/runners/empathy.rb
44
+ - lib/legion/extensions/empathy/version.rb
45
+ - spec/legion/extensions/empathy/client_spec.rb
46
+ - spec/legion/extensions/empathy/helpers/constants_spec.rb
47
+ - spec/legion/extensions/empathy/helpers/mental_model_spec.rb
48
+ - spec/legion/extensions/empathy/helpers/model_store_spec.rb
49
+ - spec/legion/extensions/empathy/runners/empathy_spec.rb
50
+ - spec/spec_helper.rb
51
+ homepage: https://github.com/LegionIO/lex-empathy
52
+ licenses:
53
+ - MIT
54
+ metadata:
55
+ homepage_uri: https://github.com/LegionIO/lex-empathy
56
+ source_code_uri: https://github.com/LegionIO/lex-empathy
57
+ documentation_uri: https://github.com/LegionIO/lex-empathy
58
+ changelog_uri: https://github.com/LegionIO/lex-empathy
59
+ bug_tracker_uri: https://github.com/LegionIO/lex-empathy/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 Empathy
78
+ test_files: []