lex-mood 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 +93 -0
- data/lex-mood.gemspec +29 -0
- data/lib/legion/extensions/mood/client.rb +17 -0
- data/lib/legion/extensions/mood/helpers/constants.rb +74 -0
- data/lib/legion/extensions/mood/helpers/mood_state.rb +150 -0
- data/lib/legion/extensions/mood/runners/mood.rb +118 -0
- data/lib/legion/extensions/mood/version.rb +9 -0
- data/lib/legion/extensions/mood.rb +15 -0
- data/spec/legion/extensions/mood/client_spec.rb +20 -0
- data/spec/legion/extensions/mood/helpers/constants_spec.rb +29 -0
- data/spec/legion/extensions/mood/helpers/mood_state_spec.rb +94 -0
- data/spec/legion/extensions/mood/runners/mood_spec.rb +71 -0
- data/spec/spec_helper.rb +35 -0
- metadata +76 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 52558938da1dfd56dce95ce2b1a368f843cc9d843b7c82276d9c75ed1ea832fa
|
|
4
|
+
data.tar.gz: cc469d9968204fb7c8087e8b52d41508ce8bbef4955f46d16bc57db88eed75d7
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 973d7eefaf080e8267db5f01d9e5b866bf3c623777f4ed72456549f9e9d65373f0142ec6493338102b92a9fb04ecb783e3887bcfab5264b86039e382511bb591
|
|
7
|
+
data.tar.gz: 5b88fdebece4cbf60e544e4372d5b6c213c1f131081c7f83f598e5d719f821b9f945cb9cc06adc8f3d02cb1c965fe09b2de5ee8f225fe2ea63a08489a1131e10
|
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,93 @@
|
|
|
1
|
+
# lex-mood
|
|
2
|
+
|
|
3
|
+
Persistent mood state for LegionIO agents. Part of the LegionIO cognitive architecture extension ecosystem (LEX).
|
|
4
|
+
|
|
5
|
+
## What It Does
|
|
6
|
+
|
|
7
|
+
`lex-mood` models the agent's sustained background affect — distinct from acute emotion. Where emotion is a reactive signal spike, mood is a slow-moving four-dimensional state (valence, arousal, energy, stability) updated via EMA. The current mood classification (one of nine states) emits modulation values that bias attention, risk tolerance, and curiosity across the cognitive architecture.
|
|
8
|
+
|
|
9
|
+
Key capabilities:
|
|
10
|
+
|
|
11
|
+
- **Nine mood states**: serene, content, curious, energized, anxious, frustrated, melancholic, flat, neutral
|
|
12
|
+
- **Four dimensions**: valence, arousal, energy, stability — all EMA-smoothed
|
|
13
|
+
- **Inertia**: each mood has a resistance coefficient preventing rapid oscillation
|
|
14
|
+
- **Modulations**: per-mood attention_threshold, risk_tolerance, and curiosity_boost values
|
|
15
|
+
- **Mood trend**: improving / stable / declining based on recent history
|
|
16
|
+
|
|
17
|
+
## Installation
|
|
18
|
+
|
|
19
|
+
Add to your Gemfile:
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
gem 'lex-mood'
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Or install directly:
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
gem install lex-mood
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Usage
|
|
32
|
+
|
|
33
|
+
```ruby
|
|
34
|
+
require 'legion/extensions/mood'
|
|
35
|
+
|
|
36
|
+
client = Legion::Extensions::Mood::Client.new
|
|
37
|
+
|
|
38
|
+
# Update mood from tick results
|
|
39
|
+
result = client.update_mood(tick_results: tick_phase_results)
|
|
40
|
+
# => { mood: :curious, valence: 0.62, arousal: 0.55, energy: 0.7,
|
|
41
|
+
# modulations: { curiosity_boost: 0.2, attention_threshold: 0.2 } }
|
|
42
|
+
|
|
43
|
+
# Query current mood
|
|
44
|
+
client.current_mood
|
|
45
|
+
# => { mood: :curious, valence: 0.62, arousal: 0.55, energy: 0.7, stability: 0.8 }
|
|
46
|
+
|
|
47
|
+
# Query a specific modulation parameter
|
|
48
|
+
client.mood_modulation(parameter: :risk_tolerance)
|
|
49
|
+
# => { parameter: :risk_tolerance, modulation: 0.5, current_mood: :curious }
|
|
50
|
+
|
|
51
|
+
# View mood history
|
|
52
|
+
client.mood_history(limit: 10)
|
|
53
|
+
|
|
54
|
+
# Summary stats
|
|
55
|
+
client.mood_stats
|
|
56
|
+
# => { current_mood: :curious, dominant_mood: :content, trend: :stable, ... }
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Mood States and Modulations
|
|
60
|
+
|
|
61
|
+
| Mood | Attention Threshold | Risk Tolerance | Curiosity Boost |
|
|
62
|
+
|---|---|---|---|
|
|
63
|
+
| serene | 0.3 | 0.5 | 0.1 |
|
|
64
|
+
| content | 0.4 | 0.5 | 0.15 |
|
|
65
|
+
| curious | 0.2 | 0.5 | 0.2 |
|
|
66
|
+
| energized | 0.3 | 0.65 | 0.1 |
|
|
67
|
+
| anxious | 0.1 | 0.2 | 0.05 |
|
|
68
|
+
| frustrated | 0.5 | 0.4 | 0.05 |
|
|
69
|
+
| melancholic | 0.5 | 0.35 | 0.05 |
|
|
70
|
+
| flat | 0.6 | 0.45 | 0.02 |
|
|
71
|
+
| neutral | 0.4 | 0.5 | 0.1 |
|
|
72
|
+
|
|
73
|
+
## Runner Methods
|
|
74
|
+
|
|
75
|
+
| Method | Description |
|
|
76
|
+
|---|---|
|
|
77
|
+
| `update_mood` | Extract valence/arousal/energy from tick results and update state |
|
|
78
|
+
| `current_mood` | Current mood symbol and all dimension values |
|
|
79
|
+
| `mood_modulation` | Specific modulation value for the current mood |
|
|
80
|
+
| `mood_history` | Recent mood state history |
|
|
81
|
+
| `mood_stats` | Current mood, dominant mood, trend, modulation table |
|
|
82
|
+
|
|
83
|
+
## Development
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
bundle install
|
|
87
|
+
bundle exec rspec
|
|
88
|
+
bundle exec rubocop
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## License
|
|
92
|
+
|
|
93
|
+
MIT
|
data/lex-mood.gemspec
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'lib/legion/extensions/mood/version'
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = 'lex-mood'
|
|
7
|
+
spec.version = Legion::Extensions::Mood::VERSION
|
|
8
|
+
spec.authors = ['Esity']
|
|
9
|
+
spec.email = ['matthewdiverson@gmail.com']
|
|
10
|
+
|
|
11
|
+
spec.summary = 'LEX Mood'
|
|
12
|
+
spec.description = 'Persistent mood state that emerges from emotional patterns and biases cognitive processing'
|
|
13
|
+
spec.homepage = 'https://github.com/LegionIO/lex-mood'
|
|
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-mood'
|
|
19
|
+
spec.metadata['documentation_uri'] = 'https://github.com/LegionIO/lex-mood'
|
|
20
|
+
spec.metadata['changelog_uri'] = 'https://github.com/LegionIO/lex-mood'
|
|
21
|
+
spec.metadata['bug_tracker_uri'] = 'https://github.com/LegionIO/lex-mood/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-mood.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 Mood
|
|
6
|
+
class Client
|
|
7
|
+
include Runners::Mood
|
|
8
|
+
|
|
9
|
+
attr_reader :mood_state
|
|
10
|
+
|
|
11
|
+
def initialize(mood_state: nil, **)
|
|
12
|
+
@mood_state = mood_state || Helpers::MoodState.new
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Mood
|
|
6
|
+
module Helpers
|
|
7
|
+
module Constants
|
|
8
|
+
# Mood states (valence x arousal quadrants + neutral)
|
|
9
|
+
MOOD_STATES = %i[
|
|
10
|
+
serene
|
|
11
|
+
content
|
|
12
|
+
curious
|
|
13
|
+
energized
|
|
14
|
+
anxious
|
|
15
|
+
frustrated
|
|
16
|
+
melancholic
|
|
17
|
+
flat
|
|
18
|
+
neutral
|
|
19
|
+
].freeze
|
|
20
|
+
|
|
21
|
+
# Mood dimensions
|
|
22
|
+
DIMENSIONS = %i[valence arousal energy stability].freeze
|
|
23
|
+
|
|
24
|
+
# EMA alpha for mood updates (slow — mood is resistant to change)
|
|
25
|
+
MOOD_ALPHA = 0.1
|
|
26
|
+
|
|
27
|
+
# How many ticks before mood recalculates
|
|
28
|
+
UPDATE_INTERVAL = 5
|
|
29
|
+
|
|
30
|
+
# Mood history capacity
|
|
31
|
+
MAX_MOOD_HISTORY = 200
|
|
32
|
+
|
|
33
|
+
# Dimension ranges for mood classification
|
|
34
|
+
MOOD_CLASSIFICATION = {
|
|
35
|
+
serene: { valence: (0.3..), arousal: (..0.3), energy: (0.3..) },
|
|
36
|
+
content: { valence: (0.3..), arousal: (0.3..0.6) },
|
|
37
|
+
curious: { valence: (0.1..), arousal: (0.4..0.7), energy: (0.4..) },
|
|
38
|
+
energized: { valence: (0.2..), arousal: (0.7..) },
|
|
39
|
+
anxious: { valence: (..0.3), arousal: (0.6..) },
|
|
40
|
+
frustrated: { valence: (..-0.1), arousal: (0.4..0.8) },
|
|
41
|
+
melancholic: { valence: (..-0.1), arousal: (..0.4) },
|
|
42
|
+
flat: { valence: (-0.2..0.2), arousal: (..0.3), energy: (..0.3) }
|
|
43
|
+
}.freeze
|
|
44
|
+
|
|
45
|
+
# Modulation effects: how each mood biases cognitive processing
|
|
46
|
+
MOOD_MODULATIONS = {
|
|
47
|
+
serene: { attention_threshold: -0.1, risk_tolerance: 0.1, curiosity_boost: 0.0 },
|
|
48
|
+
content: { attention_threshold: 0.0, risk_tolerance: 0.05, curiosity_boost: 0.05 },
|
|
49
|
+
curious: { attention_threshold: -0.15, risk_tolerance: 0.1, curiosity_boost: 0.2 },
|
|
50
|
+
energized: { attention_threshold: -0.05, risk_tolerance: 0.15, curiosity_boost: 0.1 },
|
|
51
|
+
anxious: { attention_threshold: -0.2, risk_tolerance: -0.2, curiosity_boost: -0.1 },
|
|
52
|
+
frustrated: { attention_threshold: 0.1, risk_tolerance: -0.1, curiosity_boost: -0.15 },
|
|
53
|
+
melancholic: { attention_threshold: 0.15, risk_tolerance: -0.15, curiosity_boost: -0.2 },
|
|
54
|
+
flat: { attention_threshold: 0.2, risk_tolerance: 0.0, curiosity_boost: -0.1 },
|
|
55
|
+
neutral: { attention_threshold: 0.0, risk_tolerance: 0.0, curiosity_boost: 0.0 }
|
|
56
|
+
}.freeze
|
|
57
|
+
|
|
58
|
+
# Mood inertia — how resistant each mood is to change
|
|
59
|
+
MOOD_INERTIA = {
|
|
60
|
+
serene: 0.7,
|
|
61
|
+
content: 0.6,
|
|
62
|
+
curious: 0.4,
|
|
63
|
+
energized: 0.3,
|
|
64
|
+
anxious: 0.5,
|
|
65
|
+
frustrated: 0.5,
|
|
66
|
+
melancholic: 0.8,
|
|
67
|
+
flat: 0.9,
|
|
68
|
+
neutral: 0.2
|
|
69
|
+
}.freeze
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Mood
|
|
6
|
+
module Helpers
|
|
7
|
+
class MoodState
|
|
8
|
+
attr_reader :current_mood, :valence, :arousal, :energy, :stability, :history, :tick_counter
|
|
9
|
+
|
|
10
|
+
def initialize
|
|
11
|
+
@valence = 0.5
|
|
12
|
+
@arousal = 0.3
|
|
13
|
+
@energy = 0.5
|
|
14
|
+
@stability = 0.8
|
|
15
|
+
@current_mood = :neutral
|
|
16
|
+
@history = []
|
|
17
|
+
@tick_counter = 0
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def update(inputs)
|
|
21
|
+
@tick_counter += 1
|
|
22
|
+
return @current_mood unless (@tick_counter % Constants::UPDATE_INTERVAL).zero?
|
|
23
|
+
|
|
24
|
+
alpha = effective_alpha
|
|
25
|
+
@valence = ema(@valence, inputs[:valence] || @valence, alpha)
|
|
26
|
+
@arousal = ema(@arousal, inputs[:arousal] || @arousal, alpha)
|
|
27
|
+
@energy = ema(@energy, inputs[:energy] || @energy, alpha)
|
|
28
|
+
|
|
29
|
+
compute_stability
|
|
30
|
+
classify_mood
|
|
31
|
+
record_history
|
|
32
|
+
|
|
33
|
+
@current_mood
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def modulations
|
|
37
|
+
Constants::MOOD_MODULATIONS.fetch(@current_mood, Constants::MOOD_MODULATIONS[:neutral])
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def inertia
|
|
41
|
+
Constants::MOOD_INERTIA.fetch(@current_mood, 0.5)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def duration_in_current_mood
|
|
45
|
+
return 0 if @history.empty?
|
|
46
|
+
|
|
47
|
+
consecutive = 0
|
|
48
|
+
@history.reverse_each do |entry|
|
|
49
|
+
break unless entry[:mood] == @current_mood
|
|
50
|
+
|
|
51
|
+
consecutive += 1
|
|
52
|
+
end
|
|
53
|
+
consecutive * Constants::UPDATE_INTERVAL
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def mood_trend(window: 20)
|
|
57
|
+
recent = @history.last(window)
|
|
58
|
+
return :insufficient_data if recent.size < 3
|
|
59
|
+
|
|
60
|
+
valences = recent.map { |h| h[:valence] }
|
|
61
|
+
avg_first = valences[0...(valences.size / 2)].sum / (valences.size / 2).to_f
|
|
62
|
+
avg_second = valences[(valences.size / 2)..].sum / (valences.size - (valences.size / 2)).to_f
|
|
63
|
+
|
|
64
|
+
delta = avg_second - avg_first
|
|
65
|
+
if delta > 0.05
|
|
66
|
+
:improving
|
|
67
|
+
elsif delta < -0.05
|
|
68
|
+
:declining
|
|
69
|
+
else
|
|
70
|
+
:stable
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def to_h
|
|
75
|
+
{
|
|
76
|
+
current_mood: @current_mood,
|
|
77
|
+
valence: @valence.round(3),
|
|
78
|
+
arousal: @arousal.round(3),
|
|
79
|
+
energy: @energy.round(3),
|
|
80
|
+
stability: @stability.round(3),
|
|
81
|
+
modulations: modulations,
|
|
82
|
+
inertia: inertia,
|
|
83
|
+
duration: duration_in_current_mood,
|
|
84
|
+
trend: mood_trend,
|
|
85
|
+
history_size: @history.size
|
|
86
|
+
}
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
|
|
91
|
+
def effective_alpha
|
|
92
|
+
base_alpha = Constants::MOOD_ALPHA
|
|
93
|
+
current_inertia = inertia
|
|
94
|
+
base_alpha * (1.0 - (current_inertia * 0.5))
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def ema(current, observed, alpha)
|
|
98
|
+
((current * (1.0 - alpha)) + (observed * alpha)).clamp(0.0, 1.0)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def compute_stability
|
|
102
|
+
return if @history.size < 3
|
|
103
|
+
|
|
104
|
+
recent_moods = @history.last(10).map { |h| h[:mood] }
|
|
105
|
+
unique_moods = recent_moods.uniq.size
|
|
106
|
+
@stability = (1.0 - (unique_moods.to_f / [recent_moods.size, 1].max)).clamp(0.0, 1.0)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def classify_mood
|
|
110
|
+
best_match = :neutral
|
|
111
|
+
best_score = 0
|
|
112
|
+
|
|
113
|
+
Constants::MOOD_CLASSIFICATION.each do |mood, criteria|
|
|
114
|
+
score = match_score(criteria)
|
|
115
|
+
if score > best_score
|
|
116
|
+
best_score = score
|
|
117
|
+
best_match = mood
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
@current_mood = best_match
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def match_score(criteria)
|
|
125
|
+
matched = 0
|
|
126
|
+
total = criteria.size
|
|
127
|
+
|
|
128
|
+
matched += 1 if criteria[:valence]&.cover?(@valence)
|
|
129
|
+
matched += 1 if criteria[:arousal]&.cover?(@arousal)
|
|
130
|
+
matched += 1 if criteria[:energy]&.cover?(@energy)
|
|
131
|
+
|
|
132
|
+
matched.to_f / [total, 1].max
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def record_history
|
|
136
|
+
@history << {
|
|
137
|
+
mood: @current_mood,
|
|
138
|
+
valence: @valence,
|
|
139
|
+
arousal: @arousal,
|
|
140
|
+
energy: @energy,
|
|
141
|
+
stability: @stability,
|
|
142
|
+
at: Time.now.utc
|
|
143
|
+
}
|
|
144
|
+
@history = @history.last(Constants::MAX_MOOD_HISTORY)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Mood
|
|
6
|
+
module Runners
|
|
7
|
+
module Mood
|
|
8
|
+
include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers) &&
|
|
9
|
+
Legion::Extensions::Helpers.const_defined?(:Lex)
|
|
10
|
+
|
|
11
|
+
def update_mood(tick_results: {}, **)
|
|
12
|
+
inputs = extract_mood_inputs(tick_results)
|
|
13
|
+
mood_state.update(inputs)
|
|
14
|
+
|
|
15
|
+
Legion::Logging.debug "[mood] #{mood_state.current_mood} (v=#{mood_state.valence.round(2)} " \
|
|
16
|
+
"a=#{mood_state.arousal.round(2)} e=#{mood_state.energy.round(2)})"
|
|
17
|
+
|
|
18
|
+
{
|
|
19
|
+
mood: mood_state.current_mood,
|
|
20
|
+
valence: mood_state.valence,
|
|
21
|
+
arousal: mood_state.arousal,
|
|
22
|
+
energy: mood_state.energy,
|
|
23
|
+
stability: mood_state.stability,
|
|
24
|
+
modulations: mood_state.modulations
|
|
25
|
+
}
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def current_mood(**)
|
|
29
|
+
Legion::Logging.debug "[mood] query: #{mood_state.current_mood}"
|
|
30
|
+
mood_state.to_h
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def mood_modulation(parameter:, **)
|
|
34
|
+
mods = mood_state.modulations
|
|
35
|
+
value = mods[parameter.to_sym]
|
|
36
|
+
|
|
37
|
+
Legion::Logging.debug "[mood] modulation: #{parameter}=#{value} (mood=#{mood_state.current_mood})"
|
|
38
|
+
|
|
39
|
+
{
|
|
40
|
+
parameter: parameter.to_sym,
|
|
41
|
+
modulation: value || 0.0,
|
|
42
|
+
current_mood: mood_state.current_mood
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def mood_history(limit: 20, **)
|
|
47
|
+
history = mood_state.history.last(limit)
|
|
48
|
+
{
|
|
49
|
+
entries: history.map { |h| { mood: h[:mood], valence: h[:valence], arousal: h[:arousal], at: h[:at] } },
|
|
50
|
+
trend: mood_state.mood_trend(window: limit),
|
|
51
|
+
count: history.size
|
|
52
|
+
}
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def mood_stats(**)
|
|
56
|
+
history = mood_state.history
|
|
57
|
+
mood_counts = Hash.new(0)
|
|
58
|
+
history.each { |h| mood_counts[h[:mood]] += 1 }
|
|
59
|
+
|
|
60
|
+
dominant = mood_counts.max_by { |_, v| v }&.first
|
|
61
|
+
|
|
62
|
+
{
|
|
63
|
+
current_mood: mood_state.current_mood,
|
|
64
|
+
stability: mood_state.stability,
|
|
65
|
+
duration: mood_state.duration_in_current_mood,
|
|
66
|
+
trend: mood_state.mood_trend,
|
|
67
|
+
dominant_mood: dominant,
|
|
68
|
+
mood_distribution: mood_counts,
|
|
69
|
+
ticks_processed: mood_state.tick_counter
|
|
70
|
+
}
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def mood_state
|
|
76
|
+
@mood_state ||= Helpers::MoodState.new
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def extract_mood_inputs(tick_results)
|
|
80
|
+
inputs = {}
|
|
81
|
+
|
|
82
|
+
emotion = tick_results[:emotional_evaluation]
|
|
83
|
+
if emotion.is_a?(Hash)
|
|
84
|
+
inputs[:valence] = normalize_emotional_valence(emotion)
|
|
85
|
+
inputs[:arousal] = emotion[:arousal] || emotion[:magnitude] || 0.3
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
inputs[:energy] = extract_energy(tick_results)
|
|
89
|
+
|
|
90
|
+
inputs
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def normalize_emotional_valence(emotion)
|
|
94
|
+
if emotion[:valence].is_a?(Hash)
|
|
95
|
+
dims = emotion[:valence]
|
|
96
|
+
positive = (dims[:importance] || 0) + (dims[:familiarity] || 0)
|
|
97
|
+
negative = (dims[:urgency] || 0) + (dims[:novelty] || 0)
|
|
98
|
+
((positive - negative + 1.0) / 2.0).clamp(0.0, 1.0)
|
|
99
|
+
elsif emotion[:magnitude].is_a?(Numeric)
|
|
100
|
+
(emotion[:magnitude] + 0.5).clamp(0.0, 1.0)
|
|
101
|
+
else
|
|
102
|
+
0.5
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def extract_energy(tick_results)
|
|
107
|
+
load = tick_results[:elapsed]
|
|
108
|
+
budget = tick_results[:budget]
|
|
109
|
+
return 0.5 unless load.is_a?(Numeric) && budget.is_a?(Numeric) && budget.positive?
|
|
110
|
+
|
|
111
|
+
utilization = load / budget
|
|
112
|
+
(1.0 - utilization).clamp(0.0, 1.0)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/mood/version'
|
|
4
|
+
require 'legion/extensions/mood/helpers/constants'
|
|
5
|
+
require 'legion/extensions/mood/helpers/mood_state'
|
|
6
|
+
require 'legion/extensions/mood/runners/mood'
|
|
7
|
+
require 'legion/extensions/mood/client'
|
|
8
|
+
|
|
9
|
+
module Legion
|
|
10
|
+
module Extensions
|
|
11
|
+
module Mood
|
|
12
|
+
extend Legion::Extensions::Core if Legion::Extensions.const_defined?(:Core)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe Legion::Extensions::Mood::Client do
|
|
4
|
+
it 'creates default mood state' do
|
|
5
|
+
client = described_class.new
|
|
6
|
+
expect(client.mood_state).to be_a(Legion::Extensions::Mood::Helpers::MoodState)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
it 'accepts injected mood state' do
|
|
10
|
+
state = Legion::Extensions::Mood::Helpers::MoodState.new
|
|
11
|
+
client = described_class.new(mood_state: state)
|
|
12
|
+
expect(client.mood_state).to equal(state)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
it 'includes Mood runner methods' do
|
|
16
|
+
client = described_class.new
|
|
17
|
+
expect(client).to respond_to(:update_mood, :current_mood, :mood_modulation,
|
|
18
|
+
:mood_history, :mood_stats)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe Legion::Extensions::Mood::Helpers::Constants do
|
|
4
|
+
it 'defines 9 mood states' do
|
|
5
|
+
expect(described_class::MOOD_STATES.size).to eq(9)
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
it 'defines 4 dimensions' do
|
|
9
|
+
expect(described_class::DIMENSIONS).to contain_exactly(:valence, :arousal, :energy, :stability)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
it 'defines modulations for every mood state' do
|
|
13
|
+
described_class::MOOD_STATES.each do |mood|
|
|
14
|
+
expect(described_class::MOOD_MODULATIONS).to have_key(mood)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
it 'defines inertia for every mood state' do
|
|
19
|
+
described_class::MOOD_STATES.each do |mood|
|
|
20
|
+
expect(described_class::MOOD_INERTIA).to have_key(mood)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
it 'has inertia values between 0 and 1' do
|
|
25
|
+
described_class::MOOD_INERTIA.each_value do |v|
|
|
26
|
+
expect(v).to be_between(0.0, 1.0)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe Legion::Extensions::Mood::Helpers::MoodState do
|
|
4
|
+
subject(:state) { described_class.new }
|
|
5
|
+
|
|
6
|
+
describe '#initialize' do
|
|
7
|
+
it 'starts neutral' do
|
|
8
|
+
expect(state.current_mood).to eq(:neutral)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
it 'starts with moderate values' do
|
|
12
|
+
expect(state.valence).to eq(0.5)
|
|
13
|
+
expect(state.arousal).to eq(0.3)
|
|
14
|
+
expect(state.energy).to eq(0.5)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
describe '#update' do
|
|
19
|
+
it 'does not change mood before update interval' do
|
|
20
|
+
state.update(valence: 0.9, arousal: 0.1)
|
|
21
|
+
expect(state.tick_counter).to eq(1)
|
|
22
|
+
expect(state.history).to be_empty
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
it 'updates mood at update interval' do
|
|
26
|
+
Legion::Extensions::Mood::Helpers::Constants::UPDATE_INTERVAL.times do
|
|
27
|
+
state.update(valence: 0.8, arousal: 0.2, energy: 0.7)
|
|
28
|
+
end
|
|
29
|
+
expect(state.history.size).to eq(1)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
it 'gradually shifts dimensions via EMA' do
|
|
33
|
+
original_valence = state.valence
|
|
34
|
+
Legion::Extensions::Mood::Helpers::Constants::UPDATE_INTERVAL.times do
|
|
35
|
+
state.update(valence: 0.9, arousal: 0.5, energy: 0.6)
|
|
36
|
+
end
|
|
37
|
+
expect(state.valence).to be > original_valence
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
it 'can reach serene mood with high valence and low arousal' do
|
|
41
|
+
20.times do
|
|
42
|
+
Legion::Extensions::Mood::Helpers::Constants::UPDATE_INTERVAL.times do
|
|
43
|
+
state.update(valence: 0.9, arousal: 0.1, energy: 0.8)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
expect(state.current_mood).to eq(:serene)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
it 'can reach anxious mood with low valence and high arousal' do
|
|
50
|
+
20.times do
|
|
51
|
+
Legion::Extensions::Mood::Helpers::Constants::UPDATE_INTERVAL.times do
|
|
52
|
+
state.update(valence: 0.1, arousal: 0.9, energy: 0.5)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
expect(state.current_mood).to eq(:anxious)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
describe '#modulations' do
|
|
60
|
+
it 'returns modulation hash for current mood' do
|
|
61
|
+
mods = state.modulations
|
|
62
|
+
expect(mods).to have_key(:attention_threshold)
|
|
63
|
+
expect(mods).to have_key(:risk_tolerance)
|
|
64
|
+
expect(mods).to have_key(:curiosity_boost)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
describe '#inertia' do
|
|
69
|
+
it 'returns inertia value for current mood' do
|
|
70
|
+
expect(state.inertia).to be_a(Numeric)
|
|
71
|
+
expect(state.inertia).to be_between(0.0, 1.0)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
describe '#duration_in_current_mood' do
|
|
76
|
+
it 'returns 0 with no history' do
|
|
77
|
+
expect(state.duration_in_current_mood).to eq(0)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
describe '#mood_trend' do
|
|
82
|
+
it 'returns :insufficient_data with few entries' do
|
|
83
|
+
expect(state.mood_trend).to eq(:insufficient_data)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
describe '#to_h' do
|
|
88
|
+
it 'returns complete state hash' do
|
|
89
|
+
h = state.to_h
|
|
90
|
+
expect(h).to include(:current_mood, :valence, :arousal, :energy,
|
|
91
|
+
:stability, :modulations, :inertia, :trend)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe Legion::Extensions::Mood::Runners::Mood do
|
|
4
|
+
let(:client) { Legion::Extensions::Mood::Client.new }
|
|
5
|
+
|
|
6
|
+
let(:tick_results) do
|
|
7
|
+
{
|
|
8
|
+
emotional_evaluation: { magnitude: 0.6, arousal: 0.4 },
|
|
9
|
+
elapsed: 2.0,
|
|
10
|
+
budget: 5.0
|
|
11
|
+
}
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
describe '#update_mood' do
|
|
15
|
+
it 'returns mood state' do
|
|
16
|
+
result = client.update_mood(tick_results: tick_results)
|
|
17
|
+
expect(result).to have_key(:mood)
|
|
18
|
+
expect(result).to have_key(:modulations)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
it 'handles empty tick results' do
|
|
22
|
+
result = client.update_mood(tick_results: {})
|
|
23
|
+
expect(result[:mood]).to be_a(Symbol)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
it 'handles valence hash from emotion' do
|
|
27
|
+
results = tick_results.merge(
|
|
28
|
+
emotional_evaluation: {
|
|
29
|
+
valence: { urgency: 0.2, importance: 0.6, novelty: 0.3, familiarity: 0.7 },
|
|
30
|
+
arousal: 0.5
|
|
31
|
+
}
|
|
32
|
+
)
|
|
33
|
+
result = client.update_mood(tick_results: results)
|
|
34
|
+
expect(result[:mood]).to be_a(Symbol)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
describe '#current_mood' do
|
|
39
|
+
it 'returns full mood state' do
|
|
40
|
+
result = client.current_mood
|
|
41
|
+
expect(result).to include(:current_mood, :valence, :arousal, :energy, :stability)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
describe '#mood_modulation' do
|
|
46
|
+
it 'returns modulation for a parameter' do
|
|
47
|
+
result = client.mood_modulation(parameter: :curiosity_boost)
|
|
48
|
+
expect(result[:parameter]).to eq(:curiosity_boost)
|
|
49
|
+
expect(result[:modulation]).to be_a(Numeric)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
it 'returns 0.0 for unknown parameter' do
|
|
53
|
+
result = client.mood_modulation(parameter: :nonexistent)
|
|
54
|
+
expect(result[:modulation]).to eq(0.0)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
describe '#mood_history' do
|
|
59
|
+
it 'returns empty history initially' do
|
|
60
|
+
result = client.mood_history
|
|
61
|
+
expect(result[:entries]).to be_empty
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
describe '#mood_stats' do
|
|
66
|
+
it 'returns stats summary' do
|
|
67
|
+
result = client.mood_stats
|
|
68
|
+
expect(result).to include(:current_mood, :stability, :trend, :ticks_processed)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
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/mood'
|
|
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,76 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: lex-mood
|
|
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: Persistent mood state that emerges from emotional patterns and biases
|
|
27
|
+
cognitive processing
|
|
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-mood.gemspec
|
|
38
|
+
- lib/legion/extensions/mood.rb
|
|
39
|
+
- lib/legion/extensions/mood/client.rb
|
|
40
|
+
- lib/legion/extensions/mood/helpers/constants.rb
|
|
41
|
+
- lib/legion/extensions/mood/helpers/mood_state.rb
|
|
42
|
+
- lib/legion/extensions/mood/runners/mood.rb
|
|
43
|
+
- lib/legion/extensions/mood/version.rb
|
|
44
|
+
- spec/legion/extensions/mood/client_spec.rb
|
|
45
|
+
- spec/legion/extensions/mood/helpers/constants_spec.rb
|
|
46
|
+
- spec/legion/extensions/mood/helpers/mood_state_spec.rb
|
|
47
|
+
- spec/legion/extensions/mood/runners/mood_spec.rb
|
|
48
|
+
- spec/spec_helper.rb
|
|
49
|
+
homepage: https://github.com/LegionIO/lex-mood
|
|
50
|
+
licenses:
|
|
51
|
+
- MIT
|
|
52
|
+
metadata:
|
|
53
|
+
homepage_uri: https://github.com/LegionIO/lex-mood
|
|
54
|
+
source_code_uri: https://github.com/LegionIO/lex-mood
|
|
55
|
+
documentation_uri: https://github.com/LegionIO/lex-mood
|
|
56
|
+
changelog_uri: https://github.com/LegionIO/lex-mood
|
|
57
|
+
bug_tracker_uri: https://github.com/LegionIO/lex-mood/issues
|
|
58
|
+
rubygems_mfa_required: 'true'
|
|
59
|
+
rdoc_options: []
|
|
60
|
+
require_paths:
|
|
61
|
+
- lib
|
|
62
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
63
|
+
requirements:
|
|
64
|
+
- - ">="
|
|
65
|
+
- !ruby/object:Gem::Version
|
|
66
|
+
version: '3.4'
|
|
67
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
68
|
+
requirements:
|
|
69
|
+
- - ">="
|
|
70
|
+
- !ruby/object:Gem::Version
|
|
71
|
+
version: '0'
|
|
72
|
+
requirements: []
|
|
73
|
+
rubygems_version: 3.6.9
|
|
74
|
+
specification_version: 4
|
|
75
|
+
summary: LEX Mood
|
|
76
|
+
test_files: []
|