lex-emotion 0.1.1
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 +15 -0
- data/LICENSE +21 -0
- data/README.md +123 -0
- data/lex-emotion.gemspec +29 -0
- data/lib/legion/extensions/emotion/actors/momentum_decay.rb +41 -0
- data/lib/legion/extensions/emotion/client.rb +32 -0
- data/lib/legion/extensions/emotion/helpers/baseline.rb +48 -0
- data/lib/legion/extensions/emotion/helpers/momentum.rb +48 -0
- data/lib/legion/extensions/emotion/helpers/valence.rb +88 -0
- data/lib/legion/extensions/emotion/runners/gut.rb +98 -0
- data/lib/legion/extensions/emotion/runners/valence.rb +116 -0
- data/lib/legion/extensions/emotion/version.rb +9 -0
- data/lib/legion/extensions/emotion.rb +16 -0
- data/spec/legion/extensions/emotion/actors/momentum_decay_spec.rb +46 -0
- data/spec/legion/extensions/emotion/client_spec.rb +46 -0
- data/spec/legion/extensions/emotion/helpers/baseline_spec.rb +48 -0
- data/spec/legion/extensions/emotion/helpers/momentum_spec.rb +45 -0
- data/spec/legion/extensions/emotion/helpers/valence_spec.rb +91 -0
- data/spec/legion/extensions/emotion/runners/gut_spec.rb +73 -0
- data/spec/legion/extensions/emotion/runners/valence_spec.rb +67 -0
- data/spec/spec_helper.rb +20 -0
- metadata +81 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 25a14df7629126e71f7e0cb1eaa60da4237f055c074bfc1709093f7601c009c5
|
|
4
|
+
data.tar.gz: c3198212e5b853f06c04add9a5d3959caf90bb38acdab7d1f595bad9dd6f933e
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 07db1bffdc454beec694dd1c75bebfe411a8b7ea122a3a7cd75e67e9760a2691da49ab32eb730190e9379fa16302787e4f10ef5ef1a8e48a073f9111171ea687
|
|
7
|
+
data.tar.gz: ccf9b2503bee116f27af9d0aa64a420c7a89991d4aedeac576663c3747e187ff6d516030f9cfc6d04d70d8b2da5d8aef6c389560675003c0093fc0a89cab0b02
|
data/Gemfile
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
source 'https://rubygems.org'
|
|
4
|
+
gemspec
|
|
5
|
+
|
|
6
|
+
group :test do
|
|
7
|
+
gem 'rake'
|
|
8
|
+
gem 'rspec'
|
|
9
|
+
gem 'rspec_junit_formatter'
|
|
10
|
+
gem 'rubocop', require: false
|
|
11
|
+
gem 'rubocop-rspec', require: false
|
|
12
|
+
gem 'simplecov'
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
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,123 @@
|
|
|
1
|
+
# lex-emotion
|
|
2
|
+
|
|
3
|
+
Multi-dimensional emotional valence system for brain-modeled agentic AI. Models emotional state across four dimensions, computes arousal and gut instinct signals, and modulates attention.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
`lex-emotion` implements the agent's affective layer. Emotional state is not a single scalar — it is a four-dimensional valence vector. Valence influences memory decay rates, attention allocation, and gut instinct signals. Momentum (exponential moving average) smooths emotional state over time.
|
|
8
|
+
|
|
9
|
+
## Valence Dimensions
|
|
10
|
+
|
|
11
|
+
| Dimension | Description |
|
|
12
|
+
|-----------|-------------|
|
|
13
|
+
| `urgency` | Time pressure and immediacy |
|
|
14
|
+
| `importance` | Impact scope and outcome severity |
|
|
15
|
+
| `novelty` | Degree of unexpectedness |
|
|
16
|
+
| `familiarity` | How well-known the domain/source is |
|
|
17
|
+
|
|
18
|
+
All dimensions are clamped to `[0.0, 1.0]`.
|
|
19
|
+
|
|
20
|
+
## Source Urgency Defaults
|
|
21
|
+
|
|
22
|
+
| Source Type | Urgency Weight |
|
|
23
|
+
|-------------|---------------|
|
|
24
|
+
| `firmware_violation` | 1.0 |
|
|
25
|
+
| `human_direct` | 0.9 |
|
|
26
|
+
| `mesh_priority` | 0.7 |
|
|
27
|
+
| `scheduled` | 0.4 |
|
|
28
|
+
| `ambient` | 0.1 |
|
|
29
|
+
|
|
30
|
+
## Installation
|
|
31
|
+
|
|
32
|
+
Add to your Gemfile:
|
|
33
|
+
|
|
34
|
+
```ruby
|
|
35
|
+
gem 'lex-emotion'
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Usage
|
|
39
|
+
|
|
40
|
+
### Evaluating Valence
|
|
41
|
+
|
|
42
|
+
```ruby
|
|
43
|
+
require 'legion/extensions/emotion'
|
|
44
|
+
|
|
45
|
+
# Evaluate a signal's emotional valence
|
|
46
|
+
result = Legion::Extensions::Emotion::Runners::Valence.evaluate_valence(
|
|
47
|
+
signal: { urgency_hint: 0.8, domain_weight: 0.9, impact_scope: 0.6,
|
|
48
|
+
reversibility: 0.2, outcome_severity: 0.8 },
|
|
49
|
+
source_type: :human_direct,
|
|
50
|
+
deadline: Time.now.utc + 3600
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
result[:valence] # => { urgency: 0.72, importance: 0.68, novelty: 0.5, familiarity: 0.0 }
|
|
54
|
+
result[:magnitude] # => 1.09 (Euclidean norm)
|
|
55
|
+
result[:dominant_dimension] # => :urgency
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### Aggregating Multiple Valences
|
|
59
|
+
|
|
60
|
+
```ruby
|
|
61
|
+
valences = [
|
|
62
|
+
{ urgency: 0.8, importance: 0.7, novelty: 0.4, familiarity: 0.3 },
|
|
63
|
+
{ urgency: 0.5, importance: 0.9, novelty: 0.2, familiarity: 0.6 }
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
Legion::Extensions::Emotion::Runners::Valence.aggregate_valences(valences: valences)
|
|
67
|
+
# => { aggregate: {...}, arousal: 0.65, dominant: :importance, count: 2 }
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### Gut Instinct
|
|
71
|
+
|
|
72
|
+
```ruby
|
|
73
|
+
# Compressed parallel query — returns a signal classification
|
|
74
|
+
result = Legion::Extensions::Emotion::Runners::Gut.gut_instinct(
|
|
75
|
+
valences: valences,
|
|
76
|
+
memory_signals: [1, 2, 3],
|
|
77
|
+
confidence_threshold: 0.5
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
result[:signal] # => :alarm | :heightened | :explore | :attend | :calm | :neutral
|
|
81
|
+
result[:confidence] # => 0.0..1.0
|
|
82
|
+
result[:reliable] # => true/false
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### Current Emotional State
|
|
86
|
+
|
|
87
|
+
```ruby
|
|
88
|
+
Legion::Extensions::Emotion::Runners::Gut.emotional_state
|
|
89
|
+
# => { momentum: { valence_ema: {...}, arousal_ema: 0.4, stability: 0.9, history_size: 5 },
|
|
90
|
+
# baseline: { urgency: {...}, ... } }
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### Attention Modulation
|
|
94
|
+
|
|
95
|
+
```ruby
|
|
96
|
+
Legion::Extensions::Emotion::Runners::Valence.modulate_attention(
|
|
97
|
+
base_salience: 0.5,
|
|
98
|
+
valence: { urgency: 0.8, importance: 0.7, novelty: 0.3, familiarity: 0.5 }
|
|
99
|
+
)
|
|
100
|
+
# => { original: 0.5, modulated: 0.72, boost: 0.22 }
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Momentum
|
|
104
|
+
|
|
105
|
+
Emotional momentum is an exponential moving average (alpha = 0.3) over valence and arousal. It tracks `valence_ema`, `arousal_ema`, `stability`, and last 100 data points.
|
|
106
|
+
|
|
107
|
+
## Actors
|
|
108
|
+
|
|
109
|
+
| Actor | Interval | Description |
|
|
110
|
+
|-------|----------|-------------|
|
|
111
|
+
| `MomentumDecay` | Every 60s | Periodically drifts emotional momentum toward neutral valence via exponential decay, preventing permanent extreme states |
|
|
112
|
+
|
|
113
|
+
## Development
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
bundle install
|
|
117
|
+
bundle exec rspec
|
|
118
|
+
bundle exec rubocop
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## License
|
|
122
|
+
|
|
123
|
+
MIT
|
data/lex-emotion.gemspec
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'lib/legion/extensions/emotion/version'
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = 'lex-emotion'
|
|
7
|
+
spec.version = Legion::Extensions::Emotion::VERSION
|
|
8
|
+
spec.authors = ['Esity']
|
|
9
|
+
spec.email = ['matthewdiverson@gmail.com']
|
|
10
|
+
|
|
11
|
+
spec.summary = 'LEX Emotion'
|
|
12
|
+
spec.description = 'Multi-dimensional emotional valence for brain-modeled agentic AI'
|
|
13
|
+
spec.homepage = 'https://github.com/LegionIO/lex-emotion'
|
|
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-emotion'
|
|
19
|
+
spec.metadata['documentation_uri'] = 'https://github.com/LegionIO/lex-emotion'
|
|
20
|
+
spec.metadata['changelog_uri'] = 'https://github.com/LegionIO/lex-emotion'
|
|
21
|
+
spec.metadata['bug_tracker_uri'] = 'https://github.com/LegionIO/lex-emotion/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-emotion.gemspec Gemfile LICENSE README.md]
|
|
26
|
+
end
|
|
27
|
+
spec.require_paths = ['lib']
|
|
28
|
+
spec.add_development_dependency 'legion-gaia'
|
|
29
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/actors/every'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module Extensions
|
|
7
|
+
module Emotion
|
|
8
|
+
module Actor
|
|
9
|
+
class MomentumDecay < Legion::Extensions::Actors::Every
|
|
10
|
+
def runner_class
|
|
11
|
+
Legion::Extensions::Emotion::Runners::Gut
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def runner_function
|
|
15
|
+
'decay_momentum'
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def time
|
|
19
|
+
60
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def run_now?
|
|
23
|
+
false
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def use_runner?
|
|
27
|
+
false
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def check_subtask?
|
|
31
|
+
false
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def generate_task?
|
|
35
|
+
false
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/emotion/helpers/valence'
|
|
4
|
+
require 'legion/extensions/emotion/helpers/baseline'
|
|
5
|
+
require 'legion/extensions/emotion/helpers/momentum'
|
|
6
|
+
require 'legion/extensions/emotion/runners/valence'
|
|
7
|
+
require 'legion/extensions/emotion/runners/gut'
|
|
8
|
+
|
|
9
|
+
module Legion
|
|
10
|
+
module Extensions
|
|
11
|
+
module Emotion
|
|
12
|
+
class Client
|
|
13
|
+
include Runners::Valence
|
|
14
|
+
include Runners::Gut
|
|
15
|
+
|
|
16
|
+
def initialize(**)
|
|
17
|
+
@emotion_baseline = Helpers::Baseline.new
|
|
18
|
+
@emotion_momentum = Helpers::Momentum.new
|
|
19
|
+
@domain_counts = Hash.new(0)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def track_domain(domain)
|
|
23
|
+
@domain_counts[domain] += 1
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
attr_reader :emotion_baseline, :emotion_momentum
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Emotion
|
|
6
|
+
module Helpers
|
|
7
|
+
class Baseline
|
|
8
|
+
ALPHA = 0.05 # slow adaptation to prevent adversarial manipulation
|
|
9
|
+
MIN_STDDEV = 0.1 # prevents division issues when baseline is stable
|
|
10
|
+
INITIAL_MEAN = 0.5
|
|
11
|
+
INITIAL_STDDEV = 0.25
|
|
12
|
+
|
|
13
|
+
attr_reader :dimensions
|
|
14
|
+
|
|
15
|
+
def initialize
|
|
16
|
+
@dimensions = Valence::DIMENSIONS.to_h do |dim|
|
|
17
|
+
[dim, { mean: INITIAL_MEAN, stddev: INITIAL_STDDEV, count: 0 }]
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def normalize(raw_score, dimension)
|
|
22
|
+
baseline = @dimensions[dimension]
|
|
23
|
+
return Valence.clamp(raw_score) unless baseline
|
|
24
|
+
|
|
25
|
+
normalized = (raw_score - baseline[:mean]) / [baseline[:stddev], MIN_STDDEV].max
|
|
26
|
+
Valence.clamp(normalized)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def update(dimension, raw_score)
|
|
30
|
+
baseline = @dimensions[dimension]
|
|
31
|
+
return unless baseline
|
|
32
|
+
|
|
33
|
+
baseline[:count] += 1
|
|
34
|
+
old_mean = baseline[:mean]
|
|
35
|
+
baseline[:mean] = (ALPHA * raw_score) + ((1.0 - ALPHA) * old_mean)
|
|
36
|
+
# Online stddev update (Welford-like with EMA)
|
|
37
|
+
deviation = (raw_score - baseline[:mean]).abs
|
|
38
|
+
baseline[:stddev] = (ALPHA * deviation) + ((1.0 - ALPHA) * baseline[:stddev])
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def get(dimension)
|
|
42
|
+
@dimensions[dimension]
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Emotion
|
|
6
|
+
module Helpers
|
|
7
|
+
class Momentum
|
|
8
|
+
attr_reader :valence_ema, :arousal_ema, :stability, :history
|
|
9
|
+
|
|
10
|
+
def initialize
|
|
11
|
+
@valence_ema = Valence.new_valence
|
|
12
|
+
@arousal_ema = 0.0
|
|
13
|
+
@stability = 1.0
|
|
14
|
+
@history = []
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def update(current_valence, current_arousal)
|
|
18
|
+
alpha = Valence::MOMENTUM_ALPHA
|
|
19
|
+
|
|
20
|
+
previous_aggregate = Valence.magnitude(@valence_ema)
|
|
21
|
+
current_aggregate = Valence.magnitude(current_valence)
|
|
22
|
+
|
|
23
|
+
@valence_ema = Valence::DIMENSIONS.to_h do |dim|
|
|
24
|
+
[dim, (alpha * current_valence[dim]) + ((1.0 - alpha) * @valence_ema[dim])]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
@arousal_ema = (alpha * current_arousal) + ((1.0 - alpha) * @arousal_ema)
|
|
28
|
+
@stability = Valence.clamp(1.0 - (current_aggregate - previous_aggregate).abs)
|
|
29
|
+
|
|
30
|
+
@history << { valence: current_valence, arousal: current_arousal, timestamp: Time.now.utc }
|
|
31
|
+
@history.shift while @history.size > 100
|
|
32
|
+
|
|
33
|
+
{ valence_ema: @valence_ema, arousal_ema: @arousal_ema, stability: @stability }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def emotional_state
|
|
37
|
+
{
|
|
38
|
+
valence_ema: @valence_ema,
|
|
39
|
+
arousal_ema: @arousal_ema,
|
|
40
|
+
stability: @stability,
|
|
41
|
+
history_size: @history.size
|
|
42
|
+
}
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Emotion
|
|
6
|
+
module Helpers
|
|
7
|
+
module Valence
|
|
8
|
+
DIMENSIONS = %i[urgency importance novelty familiarity].freeze
|
|
9
|
+
MAX_MAGNITUDE = Math.sqrt(4.0) # all 4 dimensions at 1.0
|
|
10
|
+
|
|
11
|
+
# Attention modulation weights (from spec Section 6.1)
|
|
12
|
+
URGENCY_ATTENTION_WEIGHT = 0.4
|
|
13
|
+
IMPORTANCE_ATTENTION_WEIGHT = 0.35
|
|
14
|
+
NOVELTY_ATTENTION_WEIGHT = 0.25
|
|
15
|
+
ATTENTION_MULTIPLIER = 0.3
|
|
16
|
+
|
|
17
|
+
# Momentum
|
|
18
|
+
MOMENTUM_ALPHA = 0.3
|
|
19
|
+
|
|
20
|
+
# Source urgency map (spec Section 3.2)
|
|
21
|
+
SOURCE_URGENCY = {
|
|
22
|
+
firmware_violation: 1.0,
|
|
23
|
+
human_direct: 0.9,
|
|
24
|
+
mesh_priority: 0.7,
|
|
25
|
+
scheduled: 0.4,
|
|
26
|
+
ambient: 0.1
|
|
27
|
+
}.freeze
|
|
28
|
+
|
|
29
|
+
# Familiarity saturation (spec Section 3.5)
|
|
30
|
+
FAMILIARITY_SATURATION = 100
|
|
31
|
+
|
|
32
|
+
module_function
|
|
33
|
+
|
|
34
|
+
def new_valence(urgency: 0.0, importance: 0.0, novelty: 0.0, familiarity: 0.0)
|
|
35
|
+
{
|
|
36
|
+
urgency: clamp(urgency),
|
|
37
|
+
importance: clamp(importance),
|
|
38
|
+
novelty: clamp(novelty),
|
|
39
|
+
familiarity: clamp(familiarity)
|
|
40
|
+
}
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def magnitude(valence)
|
|
44
|
+
Math.sqrt(
|
|
45
|
+
(valence[:urgency]**2) +
|
|
46
|
+
(valence[:importance]**2) +
|
|
47
|
+
(valence[:novelty]**2) +
|
|
48
|
+
(valence[:familiarity]**2)
|
|
49
|
+
)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def dominant_dimension(valence)
|
|
53
|
+
DIMENSIONS.max_by { |d| valence[d] }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def aggregate(valences)
|
|
57
|
+
return new_valence if valences.empty?
|
|
58
|
+
|
|
59
|
+
sums = Hash.new(0.0)
|
|
60
|
+
valences.each do |v|
|
|
61
|
+
DIMENSIONS.each { |d| sums[d] += v[d] }
|
|
62
|
+
end
|
|
63
|
+
n = valences.size.to_f
|
|
64
|
+
new_valence(**DIMENSIONS.to_h { |d| [d, sums[d] / n] })
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def compute_arousal(valences)
|
|
68
|
+
return 0.0 if valences.empty?
|
|
69
|
+
|
|
70
|
+
total = valences.sum { |v| magnitude(v) }
|
|
71
|
+
clamp(total / (valences.size * MAX_MAGNITUDE))
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def modulate_salience(base_salience, valence)
|
|
75
|
+
boost = ((valence[:urgency] * URGENCY_ATTENTION_WEIGHT) +
|
|
76
|
+
(valence[:importance] * IMPORTANCE_ATTENTION_WEIGHT) +
|
|
77
|
+
(valence[:novelty] * NOVELTY_ATTENTION_WEIGHT)) * ATTENTION_MULTIPLIER
|
|
78
|
+
clamp(base_salience + boost)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def clamp(value, min = 0.0, max = 1.0)
|
|
82
|
+
value.clamp(min, max)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Emotion
|
|
6
|
+
module Runners
|
|
7
|
+
module Gut
|
|
8
|
+
include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers) &&
|
|
9
|
+
Legion::Extensions::Helpers.const_defined?(:Lex)
|
|
10
|
+
|
|
11
|
+
def gut_instinct(valences:, memory_signals: [], confidence_threshold: 0.5, **)
|
|
12
|
+
return { signal: :neutral, confidence: 0.0, basis: :insufficient_data } if valences.empty?
|
|
13
|
+
|
|
14
|
+
arousal = Helpers::Valence.compute_arousal(valences)
|
|
15
|
+
aggregate = Helpers::Valence.aggregate(valences)
|
|
16
|
+
dominant = Helpers::Valence.dominant_dimension(aggregate)
|
|
17
|
+
|
|
18
|
+
signal = determine_signal(aggregate, arousal)
|
|
19
|
+
confidence = compute_confidence(valences, memory_signals)
|
|
20
|
+
|
|
21
|
+
Legion::Logging.debug "[emotion] gut instinct: signal=#{signal} confidence=#{confidence.round(2)} " \
|
|
22
|
+
"arousal=#{arousal.round(2)} dominant=#{dominant} reliable=#{confidence >= confidence_threshold}"
|
|
23
|
+
|
|
24
|
+
result = {
|
|
25
|
+
signal: signal,
|
|
26
|
+
confidence: confidence,
|
|
27
|
+
arousal: arousal,
|
|
28
|
+
dominant: dominant,
|
|
29
|
+
aggregate: aggregate,
|
|
30
|
+
reliable: confidence >= confidence_threshold
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
# Update momentum if available
|
|
34
|
+
emotion_momentum.update(aggregate, arousal) if respond_to?(:emotion_momentum, true)
|
|
35
|
+
|
|
36
|
+
result
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def decay_momentum(**)
|
|
40
|
+
neutral = { urgency: 0.5, importance: 0.5, novelty: 0.5, familiarity: 0.5 }
|
|
41
|
+
momentum = emotion_momentum
|
|
42
|
+
momentum.update(neutral, 0.5)
|
|
43
|
+
stability = momentum.stability
|
|
44
|
+
|
|
45
|
+
Legion::Logging.debug "[emotion] momentum decay: stability=#{stability.round(2)}"
|
|
46
|
+
|
|
47
|
+
{ decayed: true, stability: stability }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def emotional_state(**)
|
|
51
|
+
momentum = emotion_momentum
|
|
52
|
+
state = momentum.emotional_state
|
|
53
|
+
Legion::Logging.debug "[emotion] state query: stability=#{state[:stability]&.round(2)}"
|
|
54
|
+
{
|
|
55
|
+
momentum: state,
|
|
56
|
+
baseline: emotion_baseline.dimensions
|
|
57
|
+
}
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def emotion_momentum
|
|
63
|
+
@emotion_momentum ||= Helpers::Momentum.new
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def determine_signal(aggregate, arousal)
|
|
67
|
+
if aggregate[:urgency] > 0.7 && aggregate[:importance] > 0.7
|
|
68
|
+
:alarm
|
|
69
|
+
elsif arousal > 0.7
|
|
70
|
+
:heightened
|
|
71
|
+
elsif aggregate[:novelty] > 0.7 && aggregate[:familiarity] < 0.3
|
|
72
|
+
:explore
|
|
73
|
+
elsif aggregate[:importance] > 0.6
|
|
74
|
+
:attend
|
|
75
|
+
elsif arousal < 0.2
|
|
76
|
+
:calm
|
|
77
|
+
else
|
|
78
|
+
:neutral
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def compute_confidence(valences, memory_signals)
|
|
83
|
+
return 0.0 if valences.empty?
|
|
84
|
+
|
|
85
|
+
magnitudes = valences.map { |v| Helpers::Valence.magnitude(v) }
|
|
86
|
+
mean_mag = magnitudes.sum / magnitudes.size
|
|
87
|
+
variance = magnitudes.sum { |m| (m - mean_mag)**2 } / magnitudes.size
|
|
88
|
+
|
|
89
|
+
consensus = Helpers::Valence.clamp(1.0 - Math.sqrt(variance))
|
|
90
|
+
evidence = Helpers::Valence.clamp(memory_signals.size / 10.0)
|
|
91
|
+
|
|
92
|
+
(consensus * 0.6) + (evidence * 0.4)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Emotion
|
|
6
|
+
module Runners
|
|
7
|
+
module Valence
|
|
8
|
+
include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers) &&
|
|
9
|
+
Legion::Extensions::Helpers.const_defined?(:Lex)
|
|
10
|
+
|
|
11
|
+
def evaluate_valence(signal:, source_type: :ambient, deadline: nil, domain: nil, **)
|
|
12
|
+
baseline = emotion_baseline
|
|
13
|
+
|
|
14
|
+
urgency_raw = compute_urgency(signal, source_type, deadline)
|
|
15
|
+
importance_raw = compute_importance(signal, domain)
|
|
16
|
+
novelty_raw = compute_novelty(signal)
|
|
17
|
+
familiarity_raw = compute_familiarity(domain)
|
|
18
|
+
|
|
19
|
+
valence = Helpers::Valence.new_valence(
|
|
20
|
+
urgency: baseline.normalize(urgency_raw, :urgency),
|
|
21
|
+
importance: baseline.normalize(importance_raw, :importance),
|
|
22
|
+
novelty: baseline.normalize(novelty_raw, :novelty),
|
|
23
|
+
familiarity: baseline.normalize(familiarity_raw, :familiarity)
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
# Update baselines with raw scores
|
|
27
|
+
Helpers::Valence::DIMENSIONS.each do |dim|
|
|
28
|
+
raw = { urgency: urgency_raw, importance: importance_raw,
|
|
29
|
+
novelty: novelty_raw, familiarity: familiarity_raw }[dim]
|
|
30
|
+
baseline.update(dim, raw)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
magnitude = Helpers::Valence.magnitude(valence)
|
|
34
|
+
dominant = Helpers::Valence.dominant_dimension(valence)
|
|
35
|
+
Legion::Logging.debug "[emotion] valence: source=#{source_type} magnitude=#{magnitude.round(2)} dominant=#{dominant} " \
|
|
36
|
+
"u=#{valence[:urgency].round(2)} i=#{valence[:importance].round(2)} " \
|
|
37
|
+
"n=#{valence[:novelty].round(2)} f=#{valence[:familiarity].round(2)}"
|
|
38
|
+
|
|
39
|
+
{
|
|
40
|
+
valence: valence,
|
|
41
|
+
magnitude: magnitude,
|
|
42
|
+
dominant_dimension: dominant
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def aggregate_valences(valences:, **)
|
|
47
|
+
aggregated = Helpers::Valence.aggregate(valences)
|
|
48
|
+
arousal = Helpers::Valence.compute_arousal(valences)
|
|
49
|
+
dominant = Helpers::Valence.dominant_dimension(aggregated)
|
|
50
|
+
|
|
51
|
+
Legion::Logging.debug "[emotion] aggregate: count=#{valences.size} arousal=#{arousal.round(2)} dominant=#{dominant}"
|
|
52
|
+
{
|
|
53
|
+
aggregate: aggregated,
|
|
54
|
+
arousal: arousal,
|
|
55
|
+
dominant: dominant,
|
|
56
|
+
count: valences.size
|
|
57
|
+
}
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def modulate_attention(base_salience:, valence:, **)
|
|
61
|
+
modulated = Helpers::Valence.modulate_salience(base_salience, valence)
|
|
62
|
+
boost = modulated - base_salience
|
|
63
|
+
Legion::Logging.debug "[emotion] attention modulation: base=#{base_salience.round(2)} modulated=#{modulated.round(2)} boost=#{boost.round(2)}"
|
|
64
|
+
{ original: base_salience, modulated: modulated, boost: boost }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def compute_arousal(valences:, **)
|
|
68
|
+
arousal = Helpers::Valence.compute_arousal(valences)
|
|
69
|
+
Legion::Logging.debug "[emotion] arousal=#{arousal.round(2)} from #{valences.size} valences"
|
|
70
|
+
{ arousal: arousal }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
def emotion_baseline
|
|
76
|
+
@emotion_baseline ||= Helpers::Baseline.new
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def compute_urgency(signal, source_type, deadline)
|
|
80
|
+
deadline_urgency = 0.0
|
|
81
|
+
if deadline
|
|
82
|
+
remaining = [(deadline - Time.now.utc).to_f, 0.0].max
|
|
83
|
+
max_window = 86_400.0 # 24 hours
|
|
84
|
+
deadline_urgency = Helpers::Valence.clamp(1.0 - (remaining / max_window))
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
source_urgency = Helpers::Valence::SOURCE_URGENCY.fetch(source_type, 0.1)
|
|
88
|
+
|
|
89
|
+
pattern_urgency = signal.is_a?(Hash) ? (signal[:urgency_hint] || 0.0) : 0.0
|
|
90
|
+
|
|
91
|
+
(deadline_urgency * 0.5) + (source_urgency * 0.3) + (pattern_urgency * 0.2)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def compute_importance(signal, _domain)
|
|
95
|
+
domain_weight = signal.is_a?(Hash) ? (signal[:domain_weight] || 0.5) : 0.5
|
|
96
|
+
impact_scope = signal.is_a?(Hash) ? (signal[:impact_scope] || 0.3) : 0.3
|
|
97
|
+
reversibility = signal.is_a?(Hash) ? (signal[:reversibility] || 0.5) : 0.5
|
|
98
|
+
outcome_severity = signal.is_a?(Hash) ? (signal[:outcome_severity] || 0.3) : 0.3
|
|
99
|
+
|
|
100
|
+
(domain_weight * 0.3) + (impact_scope * 0.2) +
|
|
101
|
+
((1.0 - reversibility) * 0.25) + (outcome_severity * 0.25)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def compute_novelty(signal)
|
|
105
|
+
signal.is_a?(Hash) ? (signal[:novelty_score] || 0.5) : 0.5
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def compute_familiarity(domain)
|
|
109
|
+
signal_count = @domain_counts&.fetch(domain, 0) || 0
|
|
110
|
+
Helpers::Valence.clamp(signal_count.to_f / Helpers::Valence::FAMILIARITY_SATURATION)
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/emotion/version'
|
|
4
|
+
require 'legion/extensions/emotion/helpers/valence'
|
|
5
|
+
require 'legion/extensions/emotion/helpers/baseline'
|
|
6
|
+
require 'legion/extensions/emotion/helpers/momentum'
|
|
7
|
+
require 'legion/extensions/emotion/runners/valence'
|
|
8
|
+
require 'legion/extensions/emotion/runners/gut'
|
|
9
|
+
|
|
10
|
+
module Legion
|
|
11
|
+
module Extensions
|
|
12
|
+
module Emotion
|
|
13
|
+
extend Legion::Extensions::Core if Legion::Extensions.const_defined? :Core
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Stub the base class before loading the actor
|
|
4
|
+
module Legion
|
|
5
|
+
module Extensions
|
|
6
|
+
module Actors
|
|
7
|
+
class Every; end # rubocop:disable Lint/EmptyClass
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
$LOADED_FEATURES << 'legion/extensions/actors/every'
|
|
13
|
+
|
|
14
|
+
require_relative '../../../../../lib/legion/extensions/emotion/actors/momentum_decay'
|
|
15
|
+
|
|
16
|
+
RSpec.describe Legion::Extensions::Emotion::Actor::MomentumDecay do
|
|
17
|
+
subject(:actor) { described_class.new }
|
|
18
|
+
|
|
19
|
+
describe '#runner_class' do
|
|
20
|
+
it { expect(actor.runner_class).to eq Legion::Extensions::Emotion::Runners::Gut }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
describe '#runner_function' do
|
|
24
|
+
it { expect(actor.runner_function).to eq 'decay_momentum' }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
describe '#time' do
|
|
28
|
+
it { expect(actor.time).to eq 60 }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
describe '#run_now?' do
|
|
32
|
+
it { expect(actor.run_now?).to be false }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
describe '#use_runner?' do
|
|
36
|
+
it { expect(actor.use_runner?).to be false }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
describe '#check_subtask?' do
|
|
40
|
+
it { expect(actor.check_subtask?).to be false }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
describe '#generate_task?' do
|
|
44
|
+
it { expect(actor.generate_task?).to be false }
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/emotion/client'
|
|
4
|
+
|
|
5
|
+
RSpec.describe Legion::Extensions::Emotion::Client do
|
|
6
|
+
let(:client) { described_class.new }
|
|
7
|
+
|
|
8
|
+
it 'responds to valence runner methods' do
|
|
9
|
+
expect(client).to respond_to(:evaluate_valence)
|
|
10
|
+
expect(client).to respond_to(:aggregate_valences)
|
|
11
|
+
expect(client).to respond_to(:modulate_attention)
|
|
12
|
+
expect(client).to respond_to(:compute_arousal)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
it 'responds to gut runner methods' do
|
|
16
|
+
expect(client).to respond_to(:gut_instinct)
|
|
17
|
+
expect(client).to respond_to(:emotional_state)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
it 'tracks domain counts for familiarity' do
|
|
21
|
+
client.track_domain('work')
|
|
22
|
+
client.track_domain('work')
|
|
23
|
+
client.track_domain('personal')
|
|
24
|
+
# Domain tracking improves familiarity scoring
|
|
25
|
+
result = client.evaluate_valence(signal: {}, domain: 'work')
|
|
26
|
+
expect(result[:valence][:familiarity]).to be >= 0.0
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
it 'round-trips a full emotional evaluation cycle' do
|
|
30
|
+
# Evaluate multiple signals
|
|
31
|
+
v1 = client.evaluate_valence(signal: { urgency_hint: 0.8 }, source_type: :human_direct)
|
|
32
|
+
v2 = client.evaluate_valence(signal: { novelty_score: 0.9 }, source_type: :ambient)
|
|
33
|
+
|
|
34
|
+
# Aggregate
|
|
35
|
+
agg = client.aggregate_valences(valences: [v1[:valence], v2[:valence]])
|
|
36
|
+
expect(agg[:count]).to eq(2)
|
|
37
|
+
|
|
38
|
+
# Gut instinct
|
|
39
|
+
gut = client.gut_instinct(valences: [v1[:valence], v2[:valence]])
|
|
40
|
+
expect(gut[:signal]).to be_a(Symbol)
|
|
41
|
+
|
|
42
|
+
# State persists
|
|
43
|
+
state = client.emotional_state
|
|
44
|
+
expect(state[:momentum][:history_size]).to be >= 1
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe Legion::Extensions::Emotion::Helpers::Baseline do
|
|
4
|
+
let(:baseline) { described_class.new }
|
|
5
|
+
|
|
6
|
+
describe '#initialize' do
|
|
7
|
+
it 'sets initial values for all dimensions' do
|
|
8
|
+
Legion::Extensions::Emotion::Helpers::Valence::DIMENSIONS.each do |dim|
|
|
9
|
+
state = baseline.get(dim)
|
|
10
|
+
expect(state[:mean]).to eq(0.5)
|
|
11
|
+
expect(state[:stddev]).to eq(0.25)
|
|
12
|
+
expect(state[:count]).to eq(0)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
describe '#normalize' do
|
|
18
|
+
it 'normalizes a raw score against baseline' do
|
|
19
|
+
result = baseline.normalize(0.5, :urgency)
|
|
20
|
+
expect(result).to be_between(0.0, 1.0)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
it 'returns higher value for scores above mean' do
|
|
24
|
+
low = baseline.normalize(0.3, :urgency)
|
|
25
|
+
high = baseline.normalize(0.9, :urgency)
|
|
26
|
+
expect(high).to be > low
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
describe '#update' do
|
|
31
|
+
it 'shifts mean toward observed values' do
|
|
32
|
+
original_mean = baseline.get(:urgency)[:mean]
|
|
33
|
+
10.times { baseline.update(:urgency, 0.9) }
|
|
34
|
+
expect(baseline.get(:urgency)[:mean]).to be > original_mean
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
it 'increments count' do
|
|
38
|
+
3.times { baseline.update(:importance, 0.5) }
|
|
39
|
+
expect(baseline.get(:importance)[:count]).to eq(3)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
it 'adapts slowly (alpha=0.05)' do
|
|
43
|
+
baseline.update(:urgency, 1.0)
|
|
44
|
+
# After one update, mean should barely move: 0.05*1.0 + 0.95*0.5 = 0.525
|
|
45
|
+
expect(baseline.get(:urgency)[:mean]).to be_within(0.001).of(0.525)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe Legion::Extensions::Emotion::Helpers::Momentum do
|
|
4
|
+
let(:momentum) { described_class.new }
|
|
5
|
+
let(:valence_mod) { Legion::Extensions::Emotion::Helpers::Valence }
|
|
6
|
+
|
|
7
|
+
describe '#initialize' do
|
|
8
|
+
it 'starts with zero state' do
|
|
9
|
+
state = momentum.emotional_state
|
|
10
|
+
expect(state[:arousal_ema]).to eq(0.0)
|
|
11
|
+
expect(state[:stability]).to eq(1.0)
|
|
12
|
+
expect(state[:history_size]).to eq(0)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
describe '#update' do
|
|
17
|
+
it 'updates EMA toward current values' do
|
|
18
|
+
v = valence_mod.new_valence(urgency: 0.8, importance: 0.7)
|
|
19
|
+
result = momentum.update(v, 0.6)
|
|
20
|
+
expect(result[:arousal_ema]).to be > 0.0
|
|
21
|
+
expect(result[:valence_ema][:urgency]).to be > 0.0
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
it 'tracks history' do
|
|
25
|
+
v = valence_mod.new_valence(urgency: 0.5)
|
|
26
|
+
3.times { momentum.update(v, 0.5) }
|
|
27
|
+
expect(momentum.emotional_state[:history_size]).to eq(3)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
it 'caps history at 100' do
|
|
31
|
+
v = valence_mod.new_valence
|
|
32
|
+
105.times { momentum.update(v, 0.1) }
|
|
33
|
+
expect(momentum.emotional_state[:history_size]).to eq(100)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
it 'computes stability as inverse of emotional change' do
|
|
37
|
+
v_calm = valence_mod.new_valence(urgency: 0.1)
|
|
38
|
+
v_alarm = valence_mod.new_valence(urgency: 1.0, importance: 1.0, novelty: 1.0, familiarity: 1.0)
|
|
39
|
+
|
|
40
|
+
momentum.update(v_calm, 0.1)
|
|
41
|
+
result = momentum.update(v_alarm, 0.9)
|
|
42
|
+
expect(result[:stability]).to be < 1.0
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe Legion::Extensions::Emotion::Helpers::Valence do
|
|
4
|
+
describe '.new_valence' do
|
|
5
|
+
it 'creates a valence with defaults' do
|
|
6
|
+
v = described_class.new_valence
|
|
7
|
+
expect(v[:urgency]).to eq(0.0)
|
|
8
|
+
expect(v[:importance]).to eq(0.0)
|
|
9
|
+
expect(v[:novelty]).to eq(0.0)
|
|
10
|
+
expect(v[:familiarity]).to eq(0.0)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
it 'creates a valence with custom values' do
|
|
14
|
+
v = described_class.new_valence(urgency: 0.8, importance: 0.6)
|
|
15
|
+
expect(v[:urgency]).to eq(0.8)
|
|
16
|
+
expect(v[:importance]).to eq(0.6)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
it 'clamps values to [0, 1]' do
|
|
20
|
+
v = described_class.new_valence(urgency: 1.5, novelty: -0.3)
|
|
21
|
+
expect(v[:urgency]).to eq(1.0)
|
|
22
|
+
expect(v[:novelty]).to eq(0.0)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
describe '.magnitude' do
|
|
27
|
+
it 'computes zero for zero valence' do
|
|
28
|
+
v = described_class.new_valence
|
|
29
|
+
expect(described_class.magnitude(v)).to eq(0.0)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
it 'computes sqrt(4) for all-ones valence' do
|
|
33
|
+
v = described_class.new_valence(urgency: 1.0, importance: 1.0, novelty: 1.0, familiarity: 1.0)
|
|
34
|
+
expect(described_class.magnitude(v)).to be_within(0.001).of(2.0)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
describe '.dominant_dimension' do
|
|
39
|
+
it 'returns the highest dimension' do
|
|
40
|
+
v = described_class.new_valence(urgency: 0.2, importance: 0.9, novelty: 0.1, familiarity: 0.3)
|
|
41
|
+
expect(described_class.dominant_dimension(v)).to eq(:importance)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
describe '.aggregate' do
|
|
46
|
+
it 'returns zero valence for empty array' do
|
|
47
|
+
result = described_class.aggregate([])
|
|
48
|
+
expect(result[:urgency]).to eq(0.0)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
it 'averages multiple valences' do
|
|
52
|
+
v1 = described_class.new_valence(urgency: 0.8, importance: 0.2)
|
|
53
|
+
v2 = described_class.new_valence(urgency: 0.4, importance: 0.6)
|
|
54
|
+
result = described_class.aggregate([v1, v2])
|
|
55
|
+
expect(result[:urgency]).to be_within(0.001).of(0.6)
|
|
56
|
+
expect(result[:importance]).to be_within(0.001).of(0.4)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
describe '.compute_arousal' do
|
|
61
|
+
it 'returns 0 for empty valences' do
|
|
62
|
+
expect(described_class.compute_arousal([])).to eq(0.0)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
it 'returns 1.0 for all-max valences' do
|
|
66
|
+
v = described_class.new_valence(urgency: 1.0, importance: 1.0, novelty: 1.0, familiarity: 1.0)
|
|
67
|
+
expect(described_class.compute_arousal([v])).to be_within(0.001).of(1.0)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
it 'returns moderate arousal for mixed valences' do
|
|
71
|
+
v = described_class.new_valence(urgency: 0.5, importance: 0.5)
|
|
72
|
+
arousal = described_class.compute_arousal([v])
|
|
73
|
+
expect(arousal).to be > 0.0
|
|
74
|
+
expect(arousal).to be < 1.0
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
describe '.modulate_salience' do
|
|
79
|
+
it 'boosts salience based on valence' do
|
|
80
|
+
v = described_class.new_valence(urgency: 0.8, importance: 0.6, novelty: 0.4)
|
|
81
|
+
modulated = described_class.modulate_salience(0.5, v)
|
|
82
|
+
expect(modulated).to be > 0.5
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
it 'clamps at 1.0' do
|
|
86
|
+
v = described_class.new_valence(urgency: 1.0, importance: 1.0, novelty: 1.0)
|
|
87
|
+
modulated = described_class.modulate_salience(0.9, v)
|
|
88
|
+
expect(modulated).to eq(1.0)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/emotion/client'
|
|
4
|
+
|
|
5
|
+
RSpec.describe Legion::Extensions::Emotion::Runners::Gut do
|
|
6
|
+
let(:client) { Legion::Extensions::Emotion::Client.new }
|
|
7
|
+
let(:valence_mod) { Legion::Extensions::Emotion::Helpers::Valence }
|
|
8
|
+
|
|
9
|
+
describe '#gut_instinct' do
|
|
10
|
+
it 'returns neutral for empty valences' do
|
|
11
|
+
result = client.gut_instinct(valences: [])
|
|
12
|
+
expect(result[:signal]).to eq(:neutral)
|
|
13
|
+
expect(result[:confidence]).to eq(0.0)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
it 'returns alarm for high urgency + importance' do
|
|
17
|
+
v = valence_mod.new_valence(urgency: 0.9, importance: 0.9, novelty: 0.5, familiarity: 0.5)
|
|
18
|
+
result = client.gut_instinct(valences: [v])
|
|
19
|
+
expect(result[:signal]).to eq(:alarm)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
it 'returns explore for high novelty + low familiarity' do
|
|
23
|
+
v = valence_mod.new_valence(urgency: 0.2, importance: 0.2, novelty: 0.9, familiarity: 0.1)
|
|
24
|
+
result = client.gut_instinct(valences: [v])
|
|
25
|
+
expect(result[:signal]).to eq(:explore)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
it 'returns calm for low arousal' do
|
|
29
|
+
v = valence_mod.new_valence(urgency: 0.05, importance: 0.05, novelty: 0.05, familiarity: 0.05)
|
|
30
|
+
result = client.gut_instinct(valences: [v])
|
|
31
|
+
expect(result[:signal]).to eq(:calm)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
it 'includes confidence and reliability' do
|
|
35
|
+
v = valence_mod.new_valence(urgency: 0.5)
|
|
36
|
+
result = client.gut_instinct(valences: [v], memory_signals: [1, 2, 3])
|
|
37
|
+
expect(result).to have_key(:confidence)
|
|
38
|
+
expect(result).to have_key(:reliable)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
it 'increases confidence with more memory evidence' do
|
|
42
|
+
v = valence_mod.new_valence(urgency: 0.5)
|
|
43
|
+
low_evidence = client.gut_instinct(valences: [v], memory_signals: [])
|
|
44
|
+
high_evidence = client.gut_instinct(valences: [v], memory_signals: Array.new(10, 1))
|
|
45
|
+
expect(high_evidence[:confidence]).to be >= low_evidence[:confidence]
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
describe '#emotional_state' do
|
|
50
|
+
it 'returns momentum and baseline state' do
|
|
51
|
+
state = client.emotional_state
|
|
52
|
+
expect(state).to have_key(:momentum)
|
|
53
|
+
expect(state).to have_key(:baseline)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
describe '#decay_momentum' do
|
|
58
|
+
it 'returns decayed: true' do
|
|
59
|
+
result = client.decay_momentum
|
|
60
|
+
expect(result[:decayed]).to be true
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
it 'returns a Float stability value' do
|
|
64
|
+
result = client.decay_momentum
|
|
65
|
+
expect(result[:stability]).to be_a(Float)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
it 'returns stability within [0.0, 1.0]' do
|
|
69
|
+
result = client.decay_momentum
|
|
70
|
+
expect(result[:stability]).to be_between(0.0, 1.0)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/emotion/client'
|
|
4
|
+
|
|
5
|
+
RSpec.describe Legion::Extensions::Emotion::Runners::Valence do
|
|
6
|
+
let(:client) { Legion::Extensions::Emotion::Client.new }
|
|
7
|
+
|
|
8
|
+
describe '#evaluate_valence' do
|
|
9
|
+
it 'returns a valence with 4 dimensions' do
|
|
10
|
+
result = client.evaluate_valence(signal: { urgency_hint: 0.5 })
|
|
11
|
+
expect(result[:valence].keys).to contain_exactly(:urgency, :importance, :novelty, :familiarity)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
it 'returns magnitude' do
|
|
15
|
+
result = client.evaluate_valence(signal: {})
|
|
16
|
+
expect(result[:magnitude]).to be >= 0.0
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
it 'returns dominant dimension' do
|
|
20
|
+
result = client.evaluate_valence(signal: { domain_weight: 0.9, impact_scope: 0.8, outcome_severity: 0.9 })
|
|
21
|
+
expect(result[:dominant_dimension]).to be_a(Symbol)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
it 'responds to source type urgency' do
|
|
25
|
+
ambient = client.evaluate_valence(signal: {}, source_type: :ambient)
|
|
26
|
+
human = client.evaluate_valence(signal: {}, source_type: :human_direct)
|
|
27
|
+
expect(human[:valence][:urgency]).to be >= ambient[:valence][:urgency]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
it 'responds to deadlines' do
|
|
31
|
+
no_deadline = client.evaluate_valence(signal: {})
|
|
32
|
+
with_deadline = client.evaluate_valence(signal: {}, deadline: Time.now.utc + 60)
|
|
33
|
+
expect(with_deadline[:valence][:urgency]).to be >= no_deadline[:valence][:urgency]
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
describe '#aggregate_valences' do
|
|
38
|
+
it 'aggregates multiple valences' do
|
|
39
|
+
v = Legion::Extensions::Emotion::Helpers::Valence
|
|
40
|
+
valences = [
|
|
41
|
+
v.new_valence(urgency: 0.8, importance: 0.2),
|
|
42
|
+
v.new_valence(urgency: 0.4, importance: 0.6)
|
|
43
|
+
]
|
|
44
|
+
result = client.aggregate_valences(valences: valences)
|
|
45
|
+
expect(result[:aggregate][:urgency]).to be_within(0.01).of(0.6)
|
|
46
|
+
expect(result[:count]).to eq(2)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
describe '#modulate_attention' do
|
|
51
|
+
it 'boosts salience' do
|
|
52
|
+
v = Legion::Extensions::Emotion::Helpers::Valence.new_valence(urgency: 0.8, importance: 0.7)
|
|
53
|
+
result = client.modulate_attention(base_salience: 0.5, valence: v)
|
|
54
|
+
expect(result[:modulated]).to be > result[:original]
|
|
55
|
+
expect(result[:boost]).to be > 0
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
describe '#compute_arousal' do
|
|
60
|
+
it 'computes arousal from valences' do
|
|
61
|
+
v = Legion::Extensions::Emotion::Helpers::Valence
|
|
62
|
+
valences = [v.new_valence(urgency: 0.9, importance: 0.9)]
|
|
63
|
+
result = client.compute_arousal(valences: valences)
|
|
64
|
+
expect(result[:arousal]).to be > 0.0
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
data/spec/spec_helper.rb
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'bundler/setup'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module Logging
|
|
7
|
+
def self.debug(_msg); end
|
|
8
|
+
def self.info(_msg); end
|
|
9
|
+
def self.warn(_msg); end
|
|
10
|
+
def self.error(_msg); end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
require 'legion/extensions/emotion'
|
|
15
|
+
|
|
16
|
+
RSpec.configure do |config|
|
|
17
|
+
config.example_status_persistence_file_path = '.rspec_status'
|
|
18
|
+
config.disable_monkey_patching!
|
|
19
|
+
config.expect_with(:rspec) { |c| c.syntax = :expect }
|
|
20
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: lex-emotion
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.1
|
|
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: Multi-dimensional emotional valence for brain-modeled agentic AI
|
|
27
|
+
email:
|
|
28
|
+
- matthewdiverson@gmail.com
|
|
29
|
+
executables: []
|
|
30
|
+
extensions: []
|
|
31
|
+
extra_rdoc_files: []
|
|
32
|
+
files:
|
|
33
|
+
- Gemfile
|
|
34
|
+
- LICENSE
|
|
35
|
+
- README.md
|
|
36
|
+
- lex-emotion.gemspec
|
|
37
|
+
- lib/legion/extensions/emotion.rb
|
|
38
|
+
- lib/legion/extensions/emotion/actors/momentum_decay.rb
|
|
39
|
+
- lib/legion/extensions/emotion/client.rb
|
|
40
|
+
- lib/legion/extensions/emotion/helpers/baseline.rb
|
|
41
|
+
- lib/legion/extensions/emotion/helpers/momentum.rb
|
|
42
|
+
- lib/legion/extensions/emotion/helpers/valence.rb
|
|
43
|
+
- lib/legion/extensions/emotion/runners/gut.rb
|
|
44
|
+
- lib/legion/extensions/emotion/runners/valence.rb
|
|
45
|
+
- lib/legion/extensions/emotion/version.rb
|
|
46
|
+
- spec/legion/extensions/emotion/actors/momentum_decay_spec.rb
|
|
47
|
+
- spec/legion/extensions/emotion/client_spec.rb
|
|
48
|
+
- spec/legion/extensions/emotion/helpers/baseline_spec.rb
|
|
49
|
+
- spec/legion/extensions/emotion/helpers/momentum_spec.rb
|
|
50
|
+
- spec/legion/extensions/emotion/helpers/valence_spec.rb
|
|
51
|
+
- spec/legion/extensions/emotion/runners/gut_spec.rb
|
|
52
|
+
- spec/legion/extensions/emotion/runners/valence_spec.rb
|
|
53
|
+
- spec/spec_helper.rb
|
|
54
|
+
homepage: https://github.com/LegionIO/lex-emotion
|
|
55
|
+
licenses:
|
|
56
|
+
- MIT
|
|
57
|
+
metadata:
|
|
58
|
+
homepage_uri: https://github.com/LegionIO/lex-emotion
|
|
59
|
+
source_code_uri: https://github.com/LegionIO/lex-emotion
|
|
60
|
+
documentation_uri: https://github.com/LegionIO/lex-emotion
|
|
61
|
+
changelog_uri: https://github.com/LegionIO/lex-emotion
|
|
62
|
+
bug_tracker_uri: https://github.com/LegionIO/lex-emotion/issues
|
|
63
|
+
rubygems_mfa_required: 'true'
|
|
64
|
+
rdoc_options: []
|
|
65
|
+
require_paths:
|
|
66
|
+
- lib
|
|
67
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
68
|
+
requirements:
|
|
69
|
+
- - ">="
|
|
70
|
+
- !ruby/object:Gem::Version
|
|
71
|
+
version: '3.4'
|
|
72
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
73
|
+
requirements:
|
|
74
|
+
- - ">="
|
|
75
|
+
- !ruby/object:Gem::Version
|
|
76
|
+
version: '0'
|
|
77
|
+
requirements: []
|
|
78
|
+
rubygems_version: 3.6.9
|
|
79
|
+
specification_version: 4
|
|
80
|
+
summary: LEX Emotion
|
|
81
|
+
test_files: []
|