lex-volition 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 +15 -0
- data/LICENSE +21 -0
- data/README.md +64 -0
- data/lex-volition.gemspec +29 -0
- data/lib/legion/extensions/volition/client.rb +23 -0
- data/lib/legion/extensions/volition/helpers/constants.rb +47 -0
- data/lib/legion/extensions/volition/helpers/drive_synthesizer.rb +154 -0
- data/lib/legion/extensions/volition/helpers/intention.rb +52 -0
- data/lib/legion/extensions/volition/helpers/intention_stack.rb +136 -0
- data/lib/legion/extensions/volition/runners/volition.rb +125 -0
- data/lib/legion/extensions/volition/version.rb +9 -0
- data/lib/legion/extensions/volition.rb +17 -0
- data/spec/legion/extensions/volition/client_spec.rb +25 -0
- data/spec/legion/extensions/volition/helpers/drive_synthesizer_spec.rb +99 -0
- data/spec/legion/extensions/volition/helpers/intention_spec.rb +93 -0
- data/spec/legion/extensions/volition/helpers/intention_stack_spec.rb +129 -0
- data/spec/legion/extensions/volition/runners/volition_spec.rb +135 -0
- data/spec/spec_helper.rb +20 -0
- metadata +78 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 27f789ca078d28bc1c55193117a6c2e70c9471e9c3c984c990a60024bd3d8ee8
|
|
4
|
+
data.tar.gz: 98f6f351eea6127dbb3b9081fb17a0cc14807e02e90281e82d854c6c5a52f9e1
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 18be092dd65a1e1af67170367d366f3db2b769063d672258155ebc13cace5d4f3fefc56ddb4922e70effdd8e642ea6a71ac86d53e12d92f82cebdebc5cec047e
|
|
7
|
+
data.tar.gz: 8bf6d6dbcd61f5c6ea75d4c7d80b106e1b2aac6116eaf01df45e90b657523614091982e83a39663e2e1218597545636f7c0e6e503b60e24f8c208e8186133b34
|
data/Gemfile
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
source 'https://rubygems.org'
|
|
4
|
+
|
|
5
|
+
gemspec
|
|
6
|
+
|
|
7
|
+
group :development, :test do
|
|
8
|
+
gem 'rake', '~> 13.0'
|
|
9
|
+
gem 'rspec', '~> 3.0'
|
|
10
|
+
gem 'rubocop', '~> 1.21'
|
|
11
|
+
gem 'rubocop-rspec', require: false
|
|
12
|
+
gem 'simplecov', require: false
|
|
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 LegionIO
|
|
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,64 @@
|
|
|
1
|
+
# lex-volition
|
|
2
|
+
|
|
3
|
+
Drive synthesis and intention formation for LegionIO cognitive agents. Computes five motivational drives from the cognitive tick cycle and produces a prioritized intention stack.
|
|
4
|
+
|
|
5
|
+
## What It Does
|
|
6
|
+
|
|
7
|
+
`lex-volition` is the `action_selection` phase of the cognitive cycle. Each tick, it reads the full output of preceding phases and synthesizes five motivational drives. Drives above the threshold generate intentions, which are pushed onto a salience-sorted stack (max 7, per Miller's Law). Intentions decay each tick and expire by age.
|
|
8
|
+
|
|
9
|
+
- **Drives**: curiosity, corrective, urgency, epistemic, social
|
|
10
|
+
- **Drive weights**: curiosity 0.25, corrective 0.20, urgency 0.20, epistemic 0.20, social 0.15
|
|
11
|
+
- **Urgency source**: gut signal from lex-emotion (`:alarm`=1.0, `:heightened`=0.7, `:explore`=0.5, `:attend`=0.4, `:calm`=0.1)
|
|
12
|
+
- **Intention decay**: -0.05 salience per tick; expire at floor (0.1) or max age (100 ticks)
|
|
13
|
+
- **Stack capacity**: 7 intentions max; lowest salience evicted on overflow
|
|
14
|
+
|
|
15
|
+
## Usage
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
require 'legion/extensions/volition'
|
|
19
|
+
|
|
20
|
+
client = Legion::Extensions::Volition::Client.new
|
|
21
|
+
|
|
22
|
+
# Form intentions from tick results (called each cognitive cycle)
|
|
23
|
+
result = client.form_intentions(
|
|
24
|
+
tick_results: {
|
|
25
|
+
gut_instinct: { signal: :heightened },
|
|
26
|
+
prediction_engine: { calibration_error: 0.4, uncertainty: 0.6 },
|
|
27
|
+
conflict_resolution: { severity: 0.3 }
|
|
28
|
+
}
|
|
29
|
+
)
|
|
30
|
+
# => { intentions_formed: 3, dominant_drive: :epistemic, stack_size: 3 }
|
|
31
|
+
|
|
32
|
+
# Check current top intention
|
|
33
|
+
client.current_intention
|
|
34
|
+
# => { intention: { drive: :epistemic, domain: :prediction, goal: '...', salience: 0.8, state: :pending } }
|
|
35
|
+
|
|
36
|
+
# Reinforce an intention (keep it salient)
|
|
37
|
+
client.reinforce_intention(intention_id: 'int_1', amount: 0.2)
|
|
38
|
+
|
|
39
|
+
# Complete an intention
|
|
40
|
+
client.complete_intention(intention_id: 'int_1')
|
|
41
|
+
|
|
42
|
+
# Suspend/resume
|
|
43
|
+
client.suspend_intention(intention_id: 'int_2')
|
|
44
|
+
client.resume_intention(intention_id: 'int_2')
|
|
45
|
+
|
|
46
|
+
# Current volition state
|
|
47
|
+
client.volition_status
|
|
48
|
+
# => { intentions: [{ drive:, goal:, salience:, state:, age_ticks: }, ...] }
|
|
49
|
+
|
|
50
|
+
# Recent history
|
|
51
|
+
client.intention_history(limit: 20)
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Development
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
bundle install
|
|
58
|
+
bundle exec rspec
|
|
59
|
+
bundle exec rubocop
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## License
|
|
63
|
+
|
|
64
|
+
MIT
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'lib/legion/extensions/volition/version'
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = 'lex-volition'
|
|
7
|
+
spec.version = Legion::Extensions::Volition::VERSION
|
|
8
|
+
spec.authors = ['Esity']
|
|
9
|
+
spec.email = ['matthewdiverson@gmail.com']
|
|
10
|
+
|
|
11
|
+
spec.summary = 'LEX Volition'
|
|
12
|
+
spec.description = 'Intention formation and drive synthesis for brain-modeled agentic AI'
|
|
13
|
+
spec.homepage = 'https://github.com/LegionIO/lex-volition'
|
|
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-volition'
|
|
19
|
+
spec.metadata['documentation_uri'] = 'https://github.com/LegionIO/lex-volition'
|
|
20
|
+
spec.metadata['changelog_uri'] = 'https://github.com/LegionIO/lex-volition'
|
|
21
|
+
spec.metadata['bug_tracker_uri'] = 'https://github.com/LegionIO/lex-volition/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-volition.gemspec Gemfile LICENSE README.md]
|
|
26
|
+
end
|
|
27
|
+
spec.require_paths = ['lib']
|
|
28
|
+
spec.add_development_dependency 'legion-gaia'
|
|
29
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/volition/helpers/constants'
|
|
4
|
+
require 'legion/extensions/volition/helpers/intention'
|
|
5
|
+
require 'legion/extensions/volition/helpers/intention_stack'
|
|
6
|
+
require 'legion/extensions/volition/helpers/drive_synthesizer'
|
|
7
|
+
require 'legion/extensions/volition/runners/volition'
|
|
8
|
+
|
|
9
|
+
module Legion
|
|
10
|
+
module Extensions
|
|
11
|
+
module Volition
|
|
12
|
+
class Client
|
|
13
|
+
include Runners::Volition
|
|
14
|
+
|
|
15
|
+
attr_reader :intention_stack
|
|
16
|
+
|
|
17
|
+
def initialize(stack: nil, **)
|
|
18
|
+
@intention_stack = stack || Helpers::IntentionStack.new
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Volition
|
|
6
|
+
module Helpers
|
|
7
|
+
module Constants
|
|
8
|
+
# Maximum active intentions
|
|
9
|
+
MAX_INTENTIONS = 7
|
|
10
|
+
|
|
11
|
+
# Drive sources and their default weights
|
|
12
|
+
DRIVE_WEIGHTS = {
|
|
13
|
+
curiosity: 0.25, # from lex-curiosity wonder intensity
|
|
14
|
+
corrective: 0.20, # from lex-reflection adaptation recommendations
|
|
15
|
+
urgency: 0.20, # from lex-emotion gut signal + arousal
|
|
16
|
+
epistemic: 0.20, # from lex-prediction confidence gaps
|
|
17
|
+
social: 0.15 # from lex-trust + mesh signals
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
# Minimum drive strength to generate an intention
|
|
21
|
+
DRIVE_THRESHOLD = 0.15
|
|
22
|
+
|
|
23
|
+
# Intention decay per tick when not reinforced
|
|
24
|
+
INTENTION_DECAY = 0.05
|
|
25
|
+
|
|
26
|
+
# Intention salience floor before removal
|
|
27
|
+
INTENTION_FLOOR = 0.1
|
|
28
|
+
|
|
29
|
+
# Maximum intention age in ticks before forced expiry
|
|
30
|
+
MAX_INTENTION_AGE = 100
|
|
31
|
+
|
|
32
|
+
# Drive labels for narrative output
|
|
33
|
+
DRIVE_LABELS = {
|
|
34
|
+
curiosity: 'knowledge seeking',
|
|
35
|
+
corrective: 'self-improvement',
|
|
36
|
+
urgency: 'urgent response',
|
|
37
|
+
epistemic: 'uncertainty reduction',
|
|
38
|
+
social: 'collaborative engagement'
|
|
39
|
+
}.freeze
|
|
40
|
+
|
|
41
|
+
# Intention states
|
|
42
|
+
STATES = %i[active suspended completed expired].freeze
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Volition
|
|
6
|
+
module Helpers
|
|
7
|
+
module DriveSynthesizer
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def synthesize(tick_results: {}, cognitive_state: {})
|
|
11
|
+
drives = {}
|
|
12
|
+
drives[:curiosity] = compute_curiosity_drive(tick_results, cognitive_state)
|
|
13
|
+
drives[:corrective] = compute_corrective_drive(cognitive_state)
|
|
14
|
+
drives[:urgency] = compute_urgency_drive(tick_results, cognitive_state)
|
|
15
|
+
drives[:epistemic] = compute_epistemic_drive(tick_results, cognitive_state)
|
|
16
|
+
drives[:social] = compute_social_drive(cognitive_state)
|
|
17
|
+
drives
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def weighted_drives(drives)
|
|
21
|
+
drives.each_with_object({}) do |(drive, strength), result|
|
|
22
|
+
weight = Constants::DRIVE_WEIGHTS[drive] || 0.0
|
|
23
|
+
result[drive] = {
|
|
24
|
+
raw: strength,
|
|
25
|
+
weighted: (strength * weight).round(4),
|
|
26
|
+
weight: weight
|
|
27
|
+
}
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def dominant_drive(drives)
|
|
32
|
+
return nil if drives.empty?
|
|
33
|
+
|
|
34
|
+
drives.max_by { |_, strength| strength }&.first
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def generate_intentions(drives, cognitive_state: {})
|
|
38
|
+
intentions = []
|
|
39
|
+
|
|
40
|
+
drives.each do |drive, strength|
|
|
41
|
+
next if strength < Constants::DRIVE_THRESHOLD
|
|
42
|
+
|
|
43
|
+
intention = build_intention_for_drive(drive, strength, cognitive_state)
|
|
44
|
+
intentions << intention if intention
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
intentions.sort_by { |i| -(i[:salience] || 0) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def compute_curiosity_drive(tick_results, cognitive_state)
|
|
51
|
+
curiosity = cognitive_state[:curiosity] || {}
|
|
52
|
+
wonder_data = tick_results[:working_memory_integration] || {}
|
|
53
|
+
|
|
54
|
+
intensity = curiosity[:intensity] || wonder_data[:curiosity_intensity] || 0.0
|
|
55
|
+
active_count = curiosity[:active_count] || wonder_data[:active_wonders] || 0
|
|
56
|
+
|
|
57
|
+
count_factor = [active_count / 5.0, 1.0].min
|
|
58
|
+
((intensity * 0.7) + (count_factor * 0.3)).clamp(0.0, 1.0)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def compute_corrective_drive(cognitive_state)
|
|
62
|
+
reflection = cognitive_state[:reflection] || {}
|
|
63
|
+
health = reflection[:health] || 1.0
|
|
64
|
+
pending = reflection[:pending_adaptations] || 0
|
|
65
|
+
|
|
66
|
+
health_gap = 1.0 - health
|
|
67
|
+
pending_factor = [pending / 3.0, 1.0].min
|
|
68
|
+
((health_gap * 0.6) + (pending_factor * 0.4)).clamp(0.0, 1.0)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def compute_urgency_drive(tick_results, cognitive_state)
|
|
72
|
+
gut = tick_results[:gut_instinct] || cognitive_state[:gut] || {}
|
|
73
|
+
emotion = tick_results[:emotional_evaluation] || {}
|
|
74
|
+
|
|
75
|
+
arousal = emotion[:arousal] || cognitive_state.dig(:emotion, :arousal) || 0.5
|
|
76
|
+
gut_signal = extract_gut_strength(gut)
|
|
77
|
+
|
|
78
|
+
((arousal * 0.5) + (gut_signal * 0.5)).clamp(0.0, 1.0)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def compute_epistemic_drive(tick_results, cognitive_state)
|
|
82
|
+
pred = tick_results[:prediction_engine] || {}
|
|
83
|
+
pred_state = cognitive_state[:prediction] || {}
|
|
84
|
+
|
|
85
|
+
confidence = pred[:confidence] || pred_state[:confidence] || 0.5
|
|
86
|
+
pending = pred_state[:pending_count] || 0
|
|
87
|
+
|
|
88
|
+
confidence_gap = 1.0 - confidence
|
|
89
|
+
pending_factor = [pending / 10.0, 1.0].min
|
|
90
|
+
((confidence_gap * 0.6) + (pending_factor * 0.4)).clamp(0.0, 1.0)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def compute_social_drive(cognitive_state)
|
|
94
|
+
mesh = cognitive_state[:mesh] || {}
|
|
95
|
+
trust = cognitive_state[:trust] || {}
|
|
96
|
+
|
|
97
|
+
peer_count = mesh[:peer_count] || 0
|
|
98
|
+
trust_level = trust[:avg_composite] || 0.5
|
|
99
|
+
|
|
100
|
+
peer_factor = [peer_count / 5.0, 1.0].min
|
|
101
|
+
((peer_factor * 0.4) + (trust_level * 0.6)).clamp(0.0, 1.0)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def extract_gut_strength(gut)
|
|
105
|
+
signal = gut[:signal]
|
|
106
|
+
return 0.3 unless signal
|
|
107
|
+
|
|
108
|
+
case signal
|
|
109
|
+
when :alarm then 1.0
|
|
110
|
+
when :heightened then 0.7
|
|
111
|
+
when :explore then 0.5
|
|
112
|
+
when :attend then 0.4
|
|
113
|
+
when :calm then 0.1
|
|
114
|
+
else 0.3
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def build_intention_for_drive(drive, strength, cognitive_state)
|
|
119
|
+
case drive
|
|
120
|
+
when :curiosity then build_curiosity_intention(strength, cognitive_state)
|
|
121
|
+
when :corrective then build_corrective_intention(strength, cognitive_state)
|
|
122
|
+
when :urgency then build_urgency_intention(strength, cognitive_state)
|
|
123
|
+
when :epistemic then build_epistemic_intention(strength, cognitive_state)
|
|
124
|
+
when :social then build_social_intention(strength, cognitive_state)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def build_curiosity_intention(strength, cognitive_state)
|
|
129
|
+
question = cognitive_state.dig(:curiosity, :top_question) || 'explore knowledge gaps'
|
|
130
|
+
domain = cognitive_state.dig(:curiosity, :top_domain) || :general
|
|
131
|
+
Intention.new_intention(drive: :curiosity, domain: domain, goal: question, salience: strength)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def build_corrective_intention(strength, cognitive_state)
|
|
135
|
+
severity = cognitive_state.dig(:reflection, :recent_severity) || 'cognitive health'
|
|
136
|
+
Intention.new_intention(drive: :corrective, domain: :self, goal: "address #{severity}", salience: strength)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def build_urgency_intention(strength, _cognitive_state)
|
|
140
|
+
Intention.new_intention(drive: :urgency, domain: :general, goal: 'respond to urgent signal', salience: strength)
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def build_epistemic_intention(strength, _cognitive_state)
|
|
144
|
+
Intention.new_intention(drive: :epistemic, domain: :general, goal: 'reduce prediction uncertainty', salience: strength)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def build_social_intention(strength, _cognitive_state)
|
|
148
|
+
Intention.new_intention(drive: :social, domain: :general, goal: 'engage with peer agents', salience: strength)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module Extensions
|
|
7
|
+
module Volition
|
|
8
|
+
module Helpers
|
|
9
|
+
module Intention
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def new_intention(drive:, domain:, goal:, salience:, context: {})
|
|
13
|
+
{
|
|
14
|
+
intention_id: SecureRandom.hex(8),
|
|
15
|
+
drive: drive.to_sym,
|
|
16
|
+
domain: domain.to_sym,
|
|
17
|
+
goal: goal,
|
|
18
|
+
salience: salience.clamp(0.0, 1.0),
|
|
19
|
+
state: :active,
|
|
20
|
+
created_at: Time.now.utc,
|
|
21
|
+
age_ticks: 0,
|
|
22
|
+
context: context
|
|
23
|
+
}
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def decay(intention)
|
|
27
|
+
new_salience = [intention[:salience] - Constants::INTENTION_DECAY, 0.0].max
|
|
28
|
+
intention.merge(salience: new_salience, age_ticks: intention[:age_ticks] + 1)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def reinforce(intention, amount: 0.1)
|
|
32
|
+
new_salience = [intention[:salience] + amount, 1.0].min
|
|
33
|
+
intention.merge(salience: new_salience)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def expired?(intention)
|
|
37
|
+
intention[:age_ticks] >= Constants::MAX_INTENTION_AGE ||
|
|
38
|
+
intention[:salience] < Constants::INTENTION_FLOOR
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def active?(intention)
|
|
42
|
+
intention[:state] == :active && !expired?(intention)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def drive_label(drive)
|
|
46
|
+
Constants::DRIVE_LABELS[drive.to_sym] || drive.to_s
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Volition
|
|
6
|
+
module Helpers
|
|
7
|
+
class IntentionStack
|
|
8
|
+
attr_reader :intentions
|
|
9
|
+
|
|
10
|
+
def initialize
|
|
11
|
+
@intentions = []
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def push(intention)
|
|
15
|
+
return :duplicate if duplicate?(intention)
|
|
16
|
+
return :capacity_full if @intentions.size >= Constants::MAX_INTENTIONS
|
|
17
|
+
|
|
18
|
+
@intentions << intention
|
|
19
|
+
sort!
|
|
20
|
+
:pushed
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def active
|
|
24
|
+
@intentions.select { |i| Intention.active?(i) }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def top
|
|
28
|
+
active.first
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def by_drive(drive)
|
|
32
|
+
@intentions.select { |i| i[:drive] == drive.to_sym && Intention.active?(i) }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def by_domain(domain)
|
|
36
|
+
@intentions.select { |i| i[:domain] == domain.to_sym && Intention.active?(i) }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def find(intention_id)
|
|
40
|
+
@intentions.find { |i| i[:intention_id] == intention_id }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def complete(intention_id)
|
|
44
|
+
intention = find(intention_id)
|
|
45
|
+
return :not_found unless intention
|
|
46
|
+
|
|
47
|
+
intention[:state] = :completed
|
|
48
|
+
intention[:completed_at] = Time.now.utc
|
|
49
|
+
:completed
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def suspend(intention_id)
|
|
53
|
+
intention = find(intention_id)
|
|
54
|
+
return :not_found unless intention
|
|
55
|
+
|
|
56
|
+
intention[:state] = :suspended
|
|
57
|
+
:suspended
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def resume(intention_id)
|
|
61
|
+
intention = find(intention_id)
|
|
62
|
+
return :not_found unless intention
|
|
63
|
+
return :not_suspended unless intention[:state] == :suspended
|
|
64
|
+
|
|
65
|
+
intention[:state] = :active
|
|
66
|
+
:resumed
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def decay_all
|
|
70
|
+
@intentions.each do |intention|
|
|
71
|
+
next unless Intention.active?(intention)
|
|
72
|
+
|
|
73
|
+
updated = Intention.decay(intention)
|
|
74
|
+
intention.merge!(updated)
|
|
75
|
+
|
|
76
|
+
intention[:state] = :expired if Intention.expired?(intention)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
expired_count = @intentions.count { |i| i[:state] == :expired }
|
|
80
|
+
prune_expired
|
|
81
|
+
expired_count
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def reinforce(intention_id, amount: 0.1)
|
|
85
|
+
intention = find(intention_id)
|
|
86
|
+
return :not_found unless intention
|
|
87
|
+
|
|
88
|
+
updated = Intention.reinforce(intention, amount: amount)
|
|
89
|
+
intention.merge!(updated)
|
|
90
|
+
sort!
|
|
91
|
+
:reinforced
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def size
|
|
95
|
+
@intentions.size
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def active_count
|
|
99
|
+
active.size
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def stats
|
|
103
|
+
by_state = @intentions.each_with_object(Hash.new(0)) { |i, h| h[i[:state]] += 1 }
|
|
104
|
+
by_drive = active.each_with_object(Hash.new(0)) { |i, h| h[i[:drive]] += 1 }
|
|
105
|
+
{
|
|
106
|
+
total: @intentions.size,
|
|
107
|
+
active: active_count,
|
|
108
|
+
by_state: by_state,
|
|
109
|
+
by_drive: by_drive,
|
|
110
|
+
top_intention: top&.slice(:intention_id, :drive, :domain, :goal, :salience)
|
|
111
|
+
}
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
private
|
|
115
|
+
|
|
116
|
+
def sort!
|
|
117
|
+
@intentions.sort_by! { |i| -(i[:salience] || 0) }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def duplicate?(intention)
|
|
121
|
+
@intentions.any? do |existing|
|
|
122
|
+
Intention.active?(existing) &&
|
|
123
|
+
existing[:drive] == intention[:drive] &&
|
|
124
|
+
existing[:domain] == intention[:domain] &&
|
|
125
|
+
existing[:goal] == intention[:goal]
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def prune_expired
|
|
130
|
+
@intentions.reject! { |i| i[:state] == :expired }
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Volition
|
|
6
|
+
module Runners
|
|
7
|
+
module Volition
|
|
8
|
+
include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers) &&
|
|
9
|
+
Legion::Extensions::Helpers.const_defined?(:Lex)
|
|
10
|
+
|
|
11
|
+
def form_intentions(tick_results: {}, cognitive_state: {}, **)
|
|
12
|
+
drives = Helpers::DriveSynthesizer.synthesize(
|
|
13
|
+
tick_results: tick_results,
|
|
14
|
+
cognitive_state: cognitive_state
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
new_intentions = Helpers::DriveSynthesizer.generate_intentions(drives, cognitive_state: cognitive_state)
|
|
18
|
+
pushed = 0
|
|
19
|
+
new_intentions.each do |intention|
|
|
20
|
+
result = intention_stack.push(intention)
|
|
21
|
+
pushed += 1 if result == :pushed
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
expired = intention_stack.decay_all
|
|
25
|
+
dominant = Helpers::DriveSynthesizer.dominant_drive(drives)
|
|
26
|
+
current = intention_stack.top
|
|
27
|
+
|
|
28
|
+
Legion::Logging.debug "[volition] drives=#{format_drives(drives)} pushed=#{pushed} expired=#{expired} " \
|
|
29
|
+
"active=#{intention_stack.active_count} top=#{current&.dig(:goal)}"
|
|
30
|
+
|
|
31
|
+
{
|
|
32
|
+
drives: drives,
|
|
33
|
+
dominant_drive: dominant,
|
|
34
|
+
new_intentions: pushed,
|
|
35
|
+
expired: expired,
|
|
36
|
+
active_intentions: intention_stack.active_count,
|
|
37
|
+
current_intention: format_intention(current)
|
|
38
|
+
}
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def current_intention(**)
|
|
42
|
+
intention = intention_stack.top
|
|
43
|
+
return { intention: nil, has_will: false } unless intention
|
|
44
|
+
|
|
45
|
+
{
|
|
46
|
+
intention: format_intention(intention),
|
|
47
|
+
has_will: true,
|
|
48
|
+
drive: intention[:drive],
|
|
49
|
+
goal: intention[:goal],
|
|
50
|
+
salience: intention[:salience]
|
|
51
|
+
}
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def complete_intention(intention_id:, **)
|
|
55
|
+
result = intention_stack.complete(intention_id)
|
|
56
|
+
Legion::Logging.info "[volition] complete intention=#{intention_id} result=#{result}"
|
|
57
|
+
{ status: result, intention_id: intention_id }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def suspend_intention(intention_id:, **)
|
|
61
|
+
result = intention_stack.suspend(intention_id)
|
|
62
|
+
Legion::Logging.info "[volition] suspend intention=#{intention_id} result=#{result}"
|
|
63
|
+
{ status: result, intention_id: intention_id }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def resume_intention(intention_id:, **)
|
|
67
|
+
result = intention_stack.resume(intention_id)
|
|
68
|
+
Legion::Logging.info "[volition] resume intention=#{intention_id} result=#{result}"
|
|
69
|
+
{ status: result, intention_id: intention_id }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def reinforce_intention(intention_id:, amount: 0.1, **)
|
|
73
|
+
result = intention_stack.reinforce(intention_id, amount: amount)
|
|
74
|
+
{ status: result, intention_id: intention_id }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def volition_status(**)
|
|
78
|
+
stats = intention_stack.stats
|
|
79
|
+
drives = Helpers::DriveSynthesizer.synthesize(tick_results: {}, cognitive_state: {})
|
|
80
|
+
|
|
81
|
+
{
|
|
82
|
+
intention_stats: stats,
|
|
83
|
+
current_drives: drives,
|
|
84
|
+
has_will: stats[:active].positive?,
|
|
85
|
+
dominant_drive: Helpers::DriveSynthesizer.dominant_drive(drives)
|
|
86
|
+
}
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def intention_history(limit: 20, **)
|
|
90
|
+
all = intention_stack.intentions.last(limit)
|
|
91
|
+
{
|
|
92
|
+
intentions: all.map { |i| format_intention(i) },
|
|
93
|
+
count: all.size
|
|
94
|
+
}
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
def intention_stack
|
|
100
|
+
@intention_stack ||= Helpers::IntentionStack.new
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def format_intention(intention)
|
|
104
|
+
return nil unless intention
|
|
105
|
+
|
|
106
|
+
{
|
|
107
|
+
intention_id: intention[:intention_id],
|
|
108
|
+
drive: intention[:drive],
|
|
109
|
+
drive_label: Helpers::Intention.drive_label(intention[:drive]),
|
|
110
|
+
domain: intention[:domain],
|
|
111
|
+
goal: intention[:goal],
|
|
112
|
+
salience: intention[:salience].round(3),
|
|
113
|
+
state: intention[:state],
|
|
114
|
+
age_ticks: intention[:age_ticks]
|
|
115
|
+
}
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def format_drives(drives)
|
|
119
|
+
drives.map { |k, v| "#{k}=#{v.round(2)}" }.join(' ')
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/volition/version'
|
|
4
|
+
require 'legion/extensions/volition/helpers/constants'
|
|
5
|
+
require 'legion/extensions/volition/helpers/intention'
|
|
6
|
+
require 'legion/extensions/volition/helpers/intention_stack'
|
|
7
|
+
require 'legion/extensions/volition/helpers/drive_synthesizer'
|
|
8
|
+
require 'legion/extensions/volition/runners/volition'
|
|
9
|
+
require 'legion/extensions/volition/client'
|
|
10
|
+
|
|
11
|
+
module Legion
|
|
12
|
+
module Extensions
|
|
13
|
+
module Volition
|
|
14
|
+
extend Legion::Extensions::Core if Legion::Extensions.const_defined? :Core
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe Legion::Extensions::Volition::Client do
|
|
4
|
+
subject(:client) { described_class.new }
|
|
5
|
+
|
|
6
|
+
it 'initializes with a default intention stack' do
|
|
7
|
+
expect(client.intention_stack).to be_a(Legion::Extensions::Volition::Helpers::IntentionStack)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
it 'accepts an injected stack' do
|
|
11
|
+
custom = Legion::Extensions::Volition::Helpers::IntentionStack.new
|
|
12
|
+
client = described_class.new(stack: custom)
|
|
13
|
+
expect(client.intention_stack).to be(custom)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
it 'includes the Volition runner' do
|
|
17
|
+
expect(client).to respond_to(:form_intentions)
|
|
18
|
+
expect(client).to respond_to(:current_intention)
|
|
19
|
+
expect(client).to respond_to(:complete_intention)
|
|
20
|
+
expect(client).to respond_to(:suspend_intention)
|
|
21
|
+
expect(client).to respond_to(:resume_intention)
|
|
22
|
+
expect(client).to respond_to(:volition_status)
|
|
23
|
+
expect(client).to respond_to(:intention_history)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe Legion::Extensions::Volition::Helpers::DriveSynthesizer do
|
|
4
|
+
let(:synth) { described_class }
|
|
5
|
+
|
|
6
|
+
describe '.synthesize' do
|
|
7
|
+
it 'returns all five drives' do
|
|
8
|
+
drives = synth.synthesize(tick_results: {}, cognitive_state: {})
|
|
9
|
+
expect(drives).to have_key(:curiosity)
|
|
10
|
+
expect(drives).to have_key(:corrective)
|
|
11
|
+
expect(drives).to have_key(:urgency)
|
|
12
|
+
expect(drives).to have_key(:epistemic)
|
|
13
|
+
expect(drives).to have_key(:social)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
it 'all drives are between 0 and 1' do
|
|
17
|
+
drives = synth.synthesize(
|
|
18
|
+
tick_results: { emotional_evaluation: { arousal: 0.9 }, gut_instinct: { signal: :alarm } },
|
|
19
|
+
cognitive_state: { curiosity: { intensity: 0.9, active_count: 10 } }
|
|
20
|
+
)
|
|
21
|
+
drives.each_value { |v| expect(v).to be_between(0.0, 1.0) }
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
describe '.compute_curiosity_drive' do
|
|
26
|
+
it 'increases with curiosity intensity' do
|
|
27
|
+
low = synth.compute_curiosity_drive({}, { curiosity: { intensity: 0.1, active_count: 0 } })
|
|
28
|
+
high = synth.compute_curiosity_drive({}, { curiosity: { intensity: 0.9, active_count: 5 } })
|
|
29
|
+
expect(high).to be > low
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
describe '.compute_corrective_drive' do
|
|
34
|
+
it 'increases with low health and pending adaptations' do
|
|
35
|
+
healthy = synth.compute_corrective_drive({ reflection: { health: 0.95, pending_adaptations: 0 } })
|
|
36
|
+
unhealthy = synth.compute_corrective_drive({ reflection: { health: 0.4, pending_adaptations: 3 } })
|
|
37
|
+
expect(unhealthy).to be > healthy
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
describe '.compute_urgency_drive' do
|
|
42
|
+
it 'increases with high arousal and alarm gut signal' do
|
|
43
|
+
calm = synth.compute_urgency_drive({ emotional_evaluation: { arousal: 0.2 }, gut_instinct: { signal: :calm } }, {})
|
|
44
|
+
urgent = synth.compute_urgency_drive({ emotional_evaluation: { arousal: 0.9 }, gut_instinct: { signal: :alarm } }, {})
|
|
45
|
+
expect(urgent).to be > calm
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
describe '.compute_epistemic_drive' do
|
|
50
|
+
it 'increases with low prediction confidence' do
|
|
51
|
+
confident = synth.compute_epistemic_drive({ prediction_engine: { confidence: 0.9 } }, {})
|
|
52
|
+
uncertain = synth.compute_epistemic_drive({ prediction_engine: { confidence: 0.2 } }, {})
|
|
53
|
+
expect(uncertain).to be > confident
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
describe '.compute_social_drive' do
|
|
58
|
+
it 'increases with peer count and trust' do
|
|
59
|
+
alone = synth.compute_social_drive({ mesh: { peer_count: 0 }, trust: { avg_composite: 0.2 } })
|
|
60
|
+
social = synth.compute_social_drive({ mesh: { peer_count: 5 }, trust: { avg_composite: 0.8 } })
|
|
61
|
+
expect(social).to be > alone
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
describe '.dominant_drive' do
|
|
66
|
+
it 'returns the strongest drive' do
|
|
67
|
+
drives = { curiosity: 0.8, corrective: 0.3, urgency: 0.5, epistemic: 0.2, social: 0.1 }
|
|
68
|
+
expect(synth.dominant_drive(drives)).to eq(:curiosity)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
it 'returns nil for empty drives' do
|
|
72
|
+
expect(synth.dominant_drive({})).to be_nil
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
describe '.generate_intentions' do
|
|
77
|
+
it 'generates intentions for drives above threshold' do
|
|
78
|
+
drives = { curiosity: 0.8, corrective: 0.05, urgency: 0.6, epistemic: 0.02, social: 0.01 }
|
|
79
|
+
intentions = synth.generate_intentions(drives)
|
|
80
|
+
expect(intentions.size).to eq(2) # curiosity and urgency above 0.15
|
|
81
|
+
expect(intentions.first[:salience]).to be >= intentions.last[:salience]
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
it 'returns empty for all drives below threshold' do
|
|
85
|
+
drives = { curiosity: 0.05, corrective: 0.01, urgency: 0.02, epistemic: 0.01, social: 0.01 }
|
|
86
|
+
expect(synth.generate_intentions(drives)).to be_empty
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
describe '.extract_gut_strength' do
|
|
91
|
+
it 'maps gut signal symbols to numeric strength' do
|
|
92
|
+
expect(synth.extract_gut_strength({ signal: :alarm })).to eq(1.0)
|
|
93
|
+
expect(synth.extract_gut_strength({ signal: :heightened })).to eq(0.7)
|
|
94
|
+
expect(synth.extract_gut_strength({ signal: :calm })).to eq(0.1)
|
|
95
|
+
expect(synth.extract_gut_strength({ signal: :neutral })).to eq(0.3)
|
|
96
|
+
expect(synth.extract_gut_strength({})).to eq(0.3)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe Legion::Extensions::Volition::Helpers::Intention do
|
|
4
|
+
let(:helper) { described_class }
|
|
5
|
+
|
|
6
|
+
describe '.new_intention' do
|
|
7
|
+
it 'creates an intention with required fields' do
|
|
8
|
+
intention = helper.new_intention(drive: :curiosity, domain: :terraform, goal: 'explore gaps', salience: 0.7)
|
|
9
|
+
expect(intention[:intention_id]).to be_a(String)
|
|
10
|
+
expect(intention[:drive]).to eq(:curiosity)
|
|
11
|
+
expect(intention[:domain]).to eq(:terraform)
|
|
12
|
+
expect(intention[:goal]).to eq('explore gaps')
|
|
13
|
+
expect(intention[:salience]).to eq(0.7)
|
|
14
|
+
expect(intention[:state]).to eq(:active)
|
|
15
|
+
expect(intention[:age_ticks]).to eq(0)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
it 'clamps salience to [0.0, 1.0]' do
|
|
19
|
+
high = helper.new_intention(drive: :urgency, domain: :general, goal: 'respond', salience: 1.5)
|
|
20
|
+
low = helper.new_intention(drive: :urgency, domain: :general, goal: 'respond', salience: -0.5)
|
|
21
|
+
expect(high[:salience]).to eq(1.0)
|
|
22
|
+
expect(low[:salience]).to eq(0.0)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
describe '.decay' do
|
|
27
|
+
it 'reduces salience and increments age' do
|
|
28
|
+
intention = helper.new_intention(drive: :curiosity, domain: :general, goal: 'test', salience: 0.5)
|
|
29
|
+
decayed = helper.decay(intention)
|
|
30
|
+
expect(decayed[:salience]).to be < 0.5
|
|
31
|
+
expect(decayed[:age_ticks]).to eq(1)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
it 'floors at 0.0' do
|
|
35
|
+
intention = helper.new_intention(drive: :curiosity, domain: :general, goal: 'test', salience: 0.01)
|
|
36
|
+
decayed = helper.decay(intention)
|
|
37
|
+
expect(decayed[:salience]).to eq(0.0)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
describe '.reinforce' do
|
|
42
|
+
it 'increases salience' do
|
|
43
|
+
intention = helper.new_intention(drive: :curiosity, domain: :general, goal: 'test', salience: 0.5)
|
|
44
|
+
reinforced = helper.reinforce(intention, amount: 0.2)
|
|
45
|
+
expect(reinforced[:salience]).to eq(0.7)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
it 'caps at 1.0' do
|
|
49
|
+
intention = helper.new_intention(drive: :curiosity, domain: :general, goal: 'test', salience: 0.9)
|
|
50
|
+
reinforced = helper.reinforce(intention, amount: 0.5)
|
|
51
|
+
expect(reinforced[:salience]).to eq(1.0)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
describe '.expired?' do
|
|
56
|
+
it 'returns true when salience below floor' do
|
|
57
|
+
intention = helper.new_intention(drive: :curiosity, domain: :general, goal: 'test', salience: 0.05)
|
|
58
|
+
expect(helper.expired?(intention)).to be true
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
it 'returns true when age exceeds max' do
|
|
62
|
+
intention = helper.new_intention(drive: :curiosity, domain: :general, goal: 'test', salience: 0.8)
|
|
63
|
+
intention[:age_ticks] = 101
|
|
64
|
+
expect(helper.expired?(intention)).to be true
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
it 'returns false for fresh intention' do
|
|
68
|
+
intention = helper.new_intention(drive: :curiosity, domain: :general, goal: 'test', salience: 0.5)
|
|
69
|
+
expect(helper.expired?(intention)).to be false
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
describe '.active?' do
|
|
74
|
+
it 'returns true for active non-expired intentions' do
|
|
75
|
+
intention = helper.new_intention(drive: :curiosity, domain: :general, goal: 'test', salience: 0.5)
|
|
76
|
+
expect(helper.active?(intention)).to be true
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
it 'returns false for completed intentions' do
|
|
80
|
+
intention = helper.new_intention(drive: :curiosity, domain: :general, goal: 'test', salience: 0.5)
|
|
81
|
+
intention[:state] = :completed
|
|
82
|
+
expect(helper.active?(intention)).to be false
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
describe '.drive_label' do
|
|
87
|
+
it 'returns human-readable labels' do
|
|
88
|
+
expect(helper.drive_label(:curiosity)).to eq('knowledge seeking')
|
|
89
|
+
expect(helper.drive_label(:corrective)).to eq('self-improvement')
|
|
90
|
+
expect(helper.drive_label(:urgency)).to eq('urgent response')
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe Legion::Extensions::Volition::Helpers::IntentionStack do
|
|
4
|
+
subject(:stack) { described_class.new }
|
|
5
|
+
|
|
6
|
+
let(:intention_mod) { Legion::Extensions::Volition::Helpers::Intention }
|
|
7
|
+
|
|
8
|
+
def make_intention(drive: :curiosity, domain: :general, goal: 'test', salience: 0.5)
|
|
9
|
+
intention_mod.new_intention(drive: drive, domain: domain, goal: goal, salience: salience)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
describe '#push' do
|
|
13
|
+
it 'adds an intention' do
|
|
14
|
+
expect(stack.push(make_intention)).to eq(:pushed)
|
|
15
|
+
expect(stack.size).to eq(1)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
it 'rejects duplicates' do
|
|
19
|
+
stack.push(make_intention(goal: 'same goal'))
|
|
20
|
+
expect(stack.push(make_intention(goal: 'same goal'))).to eq(:duplicate)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
it 'rejects when at capacity' do
|
|
24
|
+
Legion::Extensions::Volition::Helpers::Constants::MAX_INTENTIONS.times do |i|
|
|
25
|
+
stack.push(make_intention(goal: "goal #{i}"))
|
|
26
|
+
end
|
|
27
|
+
expect(stack.push(make_intention(goal: 'overflow'))).to eq(:capacity_full)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
it 'sorts by salience descending' do
|
|
31
|
+
stack.push(make_intention(goal: 'low', salience: 0.3))
|
|
32
|
+
stack.push(make_intention(goal: 'high', salience: 0.9))
|
|
33
|
+
expect(stack.top[:goal]).to eq('high')
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
describe '#active' do
|
|
38
|
+
it 'returns only active intentions' do
|
|
39
|
+
stack.push(make_intention(goal: 'active', salience: 0.5))
|
|
40
|
+
stack.push(make_intention(goal: 'completed', salience: 0.8))
|
|
41
|
+
stack.complete(stack.top[:intention_id])
|
|
42
|
+
expect(stack.active.size).to eq(1)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
describe '#top' do
|
|
47
|
+
it 'returns highest-salience active intention' do
|
|
48
|
+
stack.push(make_intention(goal: 'low', salience: 0.3))
|
|
49
|
+
stack.push(make_intention(goal: 'high', salience: 0.9))
|
|
50
|
+
expect(stack.top[:goal]).to eq('high')
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
it 'returns nil when empty' do
|
|
54
|
+
expect(stack.top).to be_nil
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
describe '#by_drive' do
|
|
59
|
+
it 'filters by drive type' do
|
|
60
|
+
stack.push(make_intention(drive: :curiosity, goal: 'c1'))
|
|
61
|
+
stack.push(make_intention(drive: :urgency, goal: 'u1'))
|
|
62
|
+
expect(stack.by_drive(:curiosity).size).to eq(1)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
describe '#complete' do
|
|
67
|
+
it 'marks an intention as completed' do
|
|
68
|
+
stack.push(make_intention(goal: 'task'))
|
|
69
|
+
id = stack.top[:intention_id]
|
|
70
|
+
expect(stack.complete(id)).to eq(:completed)
|
|
71
|
+
expect(stack.active).to be_empty
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
it 'returns :not_found for unknown id' do
|
|
75
|
+
expect(stack.complete('nonexistent')).to eq(:not_found)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
describe '#suspend and #resume' do
|
|
80
|
+
it 'suspends and resumes' do
|
|
81
|
+
stack.push(make_intention(goal: 'task'))
|
|
82
|
+
id = stack.top[:intention_id]
|
|
83
|
+
|
|
84
|
+
expect(stack.suspend(id)).to eq(:suspended)
|
|
85
|
+
expect(stack.active).to be_empty
|
|
86
|
+
|
|
87
|
+
expect(stack.resume(id)).to eq(:resumed)
|
|
88
|
+
expect(stack.active.size).to eq(1)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
describe '#decay_all' do
|
|
93
|
+
it 'decays active intentions and expires low-salience ones' do
|
|
94
|
+
stack.push(make_intention(goal: 'fading', salience: 0.12))
|
|
95
|
+
# First decay: 0.12 - 0.05 = 0.07 < floor(0.1), so expires immediately
|
|
96
|
+
expired = stack.decay_all
|
|
97
|
+
expect(expired).to be >= 1
|
|
98
|
+
expect(stack.active_count).to eq(0)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
it 'preserves high-salience intentions through decay' do
|
|
102
|
+
stack.push(make_intention(goal: 'strong', salience: 0.8))
|
|
103
|
+
stack.decay_all
|
|
104
|
+
expect(stack.active_count).to eq(1)
|
|
105
|
+
expect(stack.top[:salience]).to be < 0.8
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
describe '#reinforce' do
|
|
110
|
+
it 'increases intention salience' do
|
|
111
|
+
stack.push(make_intention(goal: 'task', salience: 0.5))
|
|
112
|
+
id = stack.top[:intention_id]
|
|
113
|
+
stack.reinforce(id, amount: 0.2)
|
|
114
|
+
expect(stack.find(id)[:salience]).to eq(0.7)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
describe '#stats' do
|
|
119
|
+
it 'returns comprehensive stats' do
|
|
120
|
+
stack.push(make_intention(drive: :curiosity, goal: 'c1', salience: 0.8))
|
|
121
|
+
stack.push(make_intention(drive: :urgency, goal: 'u1', salience: 0.6))
|
|
122
|
+
stats = stack.stats
|
|
123
|
+
expect(stats[:total]).to eq(2)
|
|
124
|
+
expect(stats[:active]).to eq(2)
|
|
125
|
+
expect(stats[:by_drive][:curiosity]).to eq(1)
|
|
126
|
+
expect(stats[:top_intention][:goal]).to eq('c1')
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe Legion::Extensions::Volition::Runners::Volition do
|
|
4
|
+
let(:client) { Legion::Extensions::Volition::Client.new }
|
|
5
|
+
|
|
6
|
+
describe '#form_intentions' do
|
|
7
|
+
it 'synthesizes drives and forms intentions' do
|
|
8
|
+
result = client.form_intentions(
|
|
9
|
+
tick_results: {
|
|
10
|
+
emotional_evaluation: { valence: 0.3, arousal: 0.7 },
|
|
11
|
+
gut_instinct: { signal: :heightened },
|
|
12
|
+
prediction_engine: { confidence: 0.3 }
|
|
13
|
+
},
|
|
14
|
+
cognitive_state: {
|
|
15
|
+
curiosity: { intensity: 0.7, active_count: 4, top_question: 'Why are traces sparse?' },
|
|
16
|
+
reflection: { health: 0.6, pending_adaptations: 2 }
|
|
17
|
+
}
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
expect(result[:drives]).to be_a(Hash)
|
|
21
|
+
expect(result[:drives].size).to eq(5)
|
|
22
|
+
expect(result[:dominant_drive]).to be_a(Symbol)
|
|
23
|
+
expect(result[:active_intentions]).to be >= 1
|
|
24
|
+
expect(result[:current_intention]).to be_a(Hash)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
it 'handles empty inputs' do
|
|
28
|
+
result = client.form_intentions(tick_results: {}, cognitive_state: {})
|
|
29
|
+
expect(result[:drives]).to be_a(Hash)
|
|
30
|
+
expect(result[:active_intentions]).to be >= 0
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
it 'decays existing intentions over multiple ticks' do
|
|
34
|
+
client.form_intentions(
|
|
35
|
+
tick_results: {},
|
|
36
|
+
cognitive_state: { curiosity: { intensity: 0.9, active_count: 5, top_question: 'test' } }
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
# Several ticks with no reinforcement
|
|
40
|
+
5.times { client.form_intentions(tick_results: {}, cognitive_state: {}) }
|
|
41
|
+
|
|
42
|
+
stats = client.volition_status
|
|
43
|
+
# Intentions may have decayed/expired
|
|
44
|
+
expect(stats[:intention_stats][:total]).to be >= 0
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
describe '#current_intention' do
|
|
49
|
+
it 'returns nil when no intentions exist' do
|
|
50
|
+
result = client.current_intention
|
|
51
|
+
expect(result[:has_will]).to be false
|
|
52
|
+
expect(result[:intention]).to be_nil
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
it 'returns the active intention after forming' do
|
|
56
|
+
client.form_intentions(
|
|
57
|
+
tick_results: {},
|
|
58
|
+
cognitive_state: { curiosity: { intensity: 0.8, active_count: 3, top_question: 'Why?' } }
|
|
59
|
+
)
|
|
60
|
+
result = client.current_intention
|
|
61
|
+
expect(result[:has_will]).to be true
|
|
62
|
+
expect(result[:goal]).to be_a(String)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
describe '#complete_intention' do
|
|
67
|
+
it 'completes an active intention' do
|
|
68
|
+
client.form_intentions(
|
|
69
|
+
tick_results: {},
|
|
70
|
+
cognitive_state: { curiosity: { intensity: 0.8, active_count: 3, top_question: 'test' } }
|
|
71
|
+
)
|
|
72
|
+
id = client.current_intention[:intention][:intention_id]
|
|
73
|
+
result = client.complete_intention(intention_id: id)
|
|
74
|
+
expect(result[:status]).to eq(:completed)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
it 'returns :not_found for unknown id' do
|
|
78
|
+
result = client.complete_intention(intention_id: 'nonexistent')
|
|
79
|
+
expect(result[:status]).to eq(:not_found)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
describe '#suspend_intention and #resume_intention' do
|
|
84
|
+
it 'suspends and resumes an intention' do
|
|
85
|
+
# Push a single intention directly to avoid multi-drive generation
|
|
86
|
+
intention = Legion::Extensions::Volition::Helpers::Intention.new_intention(
|
|
87
|
+
drive: :curiosity, domain: :general, goal: 'test suspend', salience: 0.8
|
|
88
|
+
)
|
|
89
|
+
client.intention_stack.push(intention)
|
|
90
|
+
id = intention[:intention_id]
|
|
91
|
+
|
|
92
|
+
expect(client.suspend_intention(intention_id: id)[:status]).to eq(:suspended)
|
|
93
|
+
expect(client.current_intention[:has_will]).to be false
|
|
94
|
+
|
|
95
|
+
expect(client.resume_intention(intention_id: id)[:status]).to eq(:resumed)
|
|
96
|
+
expect(client.current_intention[:has_will]).to be true
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
describe '#reinforce_intention' do
|
|
101
|
+
it 'increases intention salience' do
|
|
102
|
+
client.form_intentions(
|
|
103
|
+
tick_results: {},
|
|
104
|
+
cognitive_state: { curiosity: { intensity: 0.5, active_count: 2, top_question: 'test' } }
|
|
105
|
+
)
|
|
106
|
+
id = client.current_intention[:intention][:intention_id]
|
|
107
|
+
old_salience = client.current_intention[:salience]
|
|
108
|
+
|
|
109
|
+
client.reinforce_intention(intention_id: id, amount: 0.3)
|
|
110
|
+
expect(client.current_intention[:salience]).to be > old_salience
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
describe '#volition_status' do
|
|
115
|
+
it 'returns comprehensive status' do
|
|
116
|
+
status = client.volition_status
|
|
117
|
+
expect(status[:intention_stats]).to be_a(Hash)
|
|
118
|
+
expect(status[:current_drives]).to be_a(Hash)
|
|
119
|
+
expect(status).to have_key(:has_will)
|
|
120
|
+
expect(status).to have_key(:dominant_drive)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
describe '#intention_history' do
|
|
125
|
+
it 'returns recent intentions' do
|
|
126
|
+
client.form_intentions(
|
|
127
|
+
tick_results: {},
|
|
128
|
+
cognitive_state: { curiosity: { intensity: 0.7, active_count: 3, top_question: 'q1' } }
|
|
129
|
+
)
|
|
130
|
+
result = client.intention_history(limit: 10)
|
|
131
|
+
expect(result[:intentions]).not_to be_empty
|
|
132
|
+
expect(result[:count]).to be >= 1
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
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/volition'
|
|
15
|
+
|
|
16
|
+
RSpec.configure do |config|
|
|
17
|
+
config.example_status_persistence_file_path = '.rspec_status'
|
|
18
|
+
config.disable_monkey_patching!
|
|
19
|
+
config.expect_with(:rspec) { |c| c.syntax = :expect }
|
|
20
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: lex-volition
|
|
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: Intention formation and drive synthesis 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-volition.gemspec
|
|
37
|
+
- lib/legion/extensions/volition.rb
|
|
38
|
+
- lib/legion/extensions/volition/client.rb
|
|
39
|
+
- lib/legion/extensions/volition/helpers/constants.rb
|
|
40
|
+
- lib/legion/extensions/volition/helpers/drive_synthesizer.rb
|
|
41
|
+
- lib/legion/extensions/volition/helpers/intention.rb
|
|
42
|
+
- lib/legion/extensions/volition/helpers/intention_stack.rb
|
|
43
|
+
- lib/legion/extensions/volition/runners/volition.rb
|
|
44
|
+
- lib/legion/extensions/volition/version.rb
|
|
45
|
+
- spec/legion/extensions/volition/client_spec.rb
|
|
46
|
+
- spec/legion/extensions/volition/helpers/drive_synthesizer_spec.rb
|
|
47
|
+
- spec/legion/extensions/volition/helpers/intention_spec.rb
|
|
48
|
+
- spec/legion/extensions/volition/helpers/intention_stack_spec.rb
|
|
49
|
+
- spec/legion/extensions/volition/runners/volition_spec.rb
|
|
50
|
+
- spec/spec_helper.rb
|
|
51
|
+
homepage: https://github.com/LegionIO/lex-volition
|
|
52
|
+
licenses:
|
|
53
|
+
- MIT
|
|
54
|
+
metadata:
|
|
55
|
+
homepage_uri: https://github.com/LegionIO/lex-volition
|
|
56
|
+
source_code_uri: https://github.com/LegionIO/lex-volition
|
|
57
|
+
documentation_uri: https://github.com/LegionIO/lex-volition
|
|
58
|
+
changelog_uri: https://github.com/LegionIO/lex-volition
|
|
59
|
+
bug_tracker_uri: https://github.com/LegionIO/lex-volition/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 Volition
|
|
78
|
+
test_files: []
|