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 +7 -0
- data/Gemfile +13 -0
- data/LICENSE +21 -0
- data/README.md +62 -0
- data/lex-empathy.gemspec +29 -0
- data/lib/legion/extensions/empathy/client.rb +17 -0
- data/lib/legion/extensions/empathy/helpers/constants.rb +50 -0
- data/lib/legion/extensions/empathy/helpers/mental_model.rb +181 -0
- data/lib/legion/extensions/empathy/helpers/model_store.rb +84 -0
- data/lib/legion/extensions/empathy/runners/empathy.rb +169 -0
- data/lib/legion/extensions/empathy/version.rb +9 -0
- data/lib/legion/extensions/empathy.rb +17 -0
- data/spec/legion/extensions/empathy/client_spec.rb +20 -0
- data/spec/legion/extensions/empathy/helpers/constants_spec.rb +23 -0
- data/spec/legion/extensions/empathy/helpers/mental_model_spec.rb +150 -0
- data/spec/legion/extensions/empathy/helpers/model_store_spec.rb +94 -0
- data/spec/legion/extensions/empathy/runners/empathy_spec.rb +127 -0
- data/spec/spec_helper.rb +35 -0
- metadata +78 -0
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
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
|
data/lex-empathy.gemspec
ADDED
|
@@ -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,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
|
data/spec/spec_helper.rb
ADDED
|
@@ -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: []
|