lex-enactive-cognition 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: bbed0f46a25d2c4adf962bc34b11afb26c788647d1df2d5a6576ff2adb37fe10
4
+ data.tar.gz: 32fc5bbcf961735ed8c9817bba4be8e558835fb25ac0392375b464d9a65fdef0
5
+ SHA512:
6
+ metadata.gz: 2999787b0454927f7cae93ce1f84831fdd0b21dfe4e3742a13b16820acaaf55ff4f7bd56fd730b930f4f3f06b2e1ed85bc80326059a3365b09c70673d2a301d3
7
+ data.tar.gz: c93bbe503a804b7b4bb8f16dd9c3e2aa05aec38a00ffe69dbe086ca1e73d990efb58298e9ae71222d56fdb1bd9815ab57aaf1f0bea4093b24dfd08c4d81c4da4
data/Gemfile ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ gemspec
6
+
7
+ gem 'rspec', '~> 3.13'
8
+ gem 'rubocop', '~> 1.75', require: false
9
+ gem 'rubocop-rspec', require: false
10
+
11
+ gem 'legion-gaia', path: '../../legion-gaia'
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'lib/legion/extensions/enactive_cognition/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'lex-enactive-cognition'
7
+ spec.version = Legion::Extensions::EnactiveCognition::VERSION
8
+ spec.authors = ['Esity']
9
+ spec.email = ['matthewdiverson@gmail.com']
10
+
11
+ spec.summary = 'LEX Enactive Cognition'
12
+ spec.description = "Varela's enactivism: cognition through action-perception loops, sensorimotor contingencies, and structural coupling"
13
+ spec.homepage = 'https://github.com/LegionIO/lex-enactive-cognition'
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-enactive-cognition'
19
+ spec.metadata['documentation_uri'] = 'https://github.com/LegionIO/lex-enactive-cognition'
20
+ spec.metadata['changelog_uri'] = 'https://github.com/LegionIO/lex-enactive-cognition'
21
+ spec.metadata['bug_tracker_uri'] = 'https://github.com/LegionIO/lex-enactive-cognition/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-enactive-cognition.gemspec Gemfile]
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/enactive_cognition/helpers/sensorimotor_loop'
4
+ require 'legion/extensions/enactive_cognition/helpers/enaction_engine'
5
+ require 'legion/extensions/enactive_cognition/runners/enactive_cognition'
6
+
7
+ module Legion
8
+ module Extensions
9
+ module EnactiveCognition
10
+ class Client
11
+ include Runners::EnactiveCognition
12
+
13
+ def initialize(**)
14
+ @enaction_engine = Helpers::EnactionEngine.new
15
+ end
16
+
17
+ private
18
+
19
+ attr_reader :enaction_engine
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module EnactiveCognition
6
+ module Helpers
7
+ class EnactionEngine
8
+ attr_reader :couplings
9
+
10
+ def initialize
11
+ @couplings = {}
12
+ end
13
+
14
+ def create_coupling(action:, perception:, domain:, loop_type: :sensorimotor)
15
+ prune_to_limit
16
+
17
+ loop = SensorimotorLoop.new(
18
+ action: action,
19
+ perception: perception,
20
+ domain: domain,
21
+ loop_type: loop_type
22
+ )
23
+ @couplings[loop.id] = loop
24
+ loop
25
+ end
26
+
27
+ def execute_action(coupling_id:, actual_perception:)
28
+ loop = @couplings[coupling_id]
29
+ return { success: false, reason: :not_found } unless loop
30
+
31
+ result = loop.execute!(actual_perception: actual_perception)
32
+ {
33
+ success: true,
34
+ coupling_id: coupling_id,
35
+ match: result[:match],
36
+ coupling_strength: result[:coupling_strength],
37
+ prediction_accuracy: result[:prediction_accuracy],
38
+ coupling_label: loop.coupling_label
39
+ }
40
+ end
41
+
42
+ def adapt_coupling(coupling_id:, new_perception:)
43
+ loop = @couplings[coupling_id]
44
+ return { success: false, reason: :not_found } unless loop
45
+
46
+ loop.adapt_perception!(new_perception: new_perception)
47
+ { success: true, coupling_id: coupling_id, new_perception: new_perception }
48
+ end
49
+
50
+ def find_action_for(perception:)
51
+ best = @couplings.values
52
+ .select(&:coupled?)
53
+ .select { |lp| lp.perception.to_s == perception.to_s }
54
+ .max_by(&:coupling_strength)
55
+ return nil unless best
56
+
57
+ best
58
+ end
59
+
60
+ def coupled_loops
61
+ @couplings.values.select(&:coupled?)
62
+ end
63
+
64
+ def by_domain(domain:)
65
+ @couplings.values.select { |lp| lp.domain.to_s == domain.to_s }
66
+ end
67
+
68
+ def by_type(loop_type:)
69
+ @couplings.values.select { |lp| lp.loop_type == loop_type }
70
+ end
71
+
72
+ def strongest_couplings(limit: 5)
73
+ @couplings.values.sort_by { |lp| -lp.coupling_strength }.first(limit)
74
+ end
75
+
76
+ def overall_coupling
77
+ return 0.0 if @couplings.empty?
78
+
79
+ total = @couplings.values.sum(&:coupling_strength)
80
+ total / @couplings.size
81
+ end
82
+
83
+ def decay_all
84
+ @couplings.each_value(&:decay!)
85
+ end
86
+
87
+ def prune_decoupled
88
+ @couplings.delete_if { |_id, lp| lp.coupling_strength < SensorimotorLoop::COUPLING_FLOOR + 0.05 }
89
+ end
90
+
91
+ def count
92
+ @couplings.size
93
+ end
94
+
95
+ def to_h
96
+ {
97
+ coupling_count: @couplings.size,
98
+ coupled_count: coupled_loops.size,
99
+ overall_coupling: overall_coupling.round(4),
100
+ strongest: strongest_couplings(limit: 3).map(&:to_h)
101
+ }
102
+ end
103
+
104
+ private
105
+
106
+ def prune_to_limit
107
+ return unless @couplings.size >= SensorimotorLoop::MAX_COUPLINGS
108
+
109
+ weakest = @couplings.values.sort_by(&:coupling_strength).first(10)
110
+ weakest.each { |lp| @couplings.delete(lp.id) }
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module Legion
6
+ module Extensions
7
+ module EnactiveCognition
8
+ module Helpers
9
+ class SensorimotorLoop
10
+ MAX_COUPLINGS = 200
11
+ MAX_ACTIONS = 500
12
+ MAX_PERCEPTIONS = 500
13
+ MAX_HISTORY = 300
14
+
15
+ DEFAULT_COUPLING_STRENGTH = 0.5
16
+ COUPLING_FLOOR = 0.0
17
+ COUPLING_CEILING = 1.0
18
+
19
+ REINFORCEMENT_RATE = 0.1
20
+ DECOUPLING_RATE = 0.15
21
+
22
+ PREDICTION_ACCURACY_THRESHOLD = 0.6
23
+
24
+ COUPLING_DECAY = 0.02
25
+ STALE_THRESHOLD = 120
26
+
27
+ COUPLING_LABELS = {
28
+ (0.8..) => :entrained,
29
+ (0.6...0.8) => :coupled,
30
+ (0.4...0.6) => :forming,
31
+ (0.2...0.4) => :weak,
32
+ (..0.2) => :decoupled
33
+ }.freeze
34
+
35
+ LOOP_TYPES = %i[sensorimotor cognitive social].freeze
36
+
37
+ attr_reader :id, :action, :perception, :domain, :loop_type,
38
+ :coupling_strength, :prediction_accuracy,
39
+ :execution_count, :accurate_predictions,
40
+ :created_at, :last_executed_at
41
+
42
+ def initialize(action:, perception:, domain:, loop_type: :sensorimotor)
43
+ @id = SecureRandom.uuid
44
+ @action = action
45
+ @perception = perception
46
+ @domain = domain
47
+ @loop_type = LOOP_TYPES.include?(loop_type) ? loop_type : :sensorimotor
48
+ @coupling_strength = DEFAULT_COUPLING_STRENGTH
49
+ @execution_count = 0
50
+ @accurate_predictions = 0
51
+ @prediction_accuracy = 0.0
52
+ @created_at = Time.now.utc
53
+ @last_executed_at = nil
54
+ end
55
+
56
+ def execute!(actual_perception:)
57
+ @execution_count += 1
58
+ @last_executed_at = Time.now.utc
59
+ match = actual_perception.to_s == @perception.to_s
60
+
61
+ if match
62
+ @accurate_predictions += 1
63
+ @coupling_strength = (@coupling_strength + REINFORCEMENT_RATE).clamp(COUPLING_FLOOR, COUPLING_CEILING)
64
+ else
65
+ @coupling_strength = (@coupling_strength - DECOUPLING_RATE).clamp(COUPLING_FLOOR, COUPLING_CEILING)
66
+ end
67
+
68
+ @prediction_accuracy = @accurate_predictions.to_f / @execution_count
69
+ { match: match, coupling_strength: @coupling_strength, prediction_accuracy: @prediction_accuracy }
70
+ end
71
+
72
+ def coupled?
73
+ coupling_strength >= 0.6
74
+ end
75
+
76
+ def coupling_label
77
+ COUPLING_LABELS.each do |range, label|
78
+ return label if range.cover?(coupling_strength)
79
+ end
80
+ :decoupled
81
+ end
82
+
83
+ def adapt_perception!(new_perception:)
84
+ @perception = new_perception
85
+ end
86
+
87
+ def decay!
88
+ @coupling_strength = (@coupling_strength - COUPLING_DECAY).clamp(COUPLING_FLOOR, COUPLING_CEILING)
89
+ end
90
+
91
+ def stale?
92
+ return false if @last_executed_at.nil?
93
+
94
+ (Time.now.utc - @last_executed_at) > STALE_THRESHOLD
95
+ end
96
+
97
+ def to_h
98
+ {
99
+ id: @id,
100
+ action: @action,
101
+ perception: @perception,
102
+ domain: @domain,
103
+ loop_type: @loop_type,
104
+ coupling_strength: @coupling_strength,
105
+ coupling_label: coupling_label,
106
+ prediction_accuracy: @prediction_accuracy,
107
+ execution_count: @execution_count,
108
+ accurate_predictions: @accurate_predictions,
109
+ coupled: coupled?,
110
+ created_at: @created_at,
111
+ last_executed_at: @last_executed_at
112
+ }
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module EnactiveCognition
6
+ module Runners
7
+ module EnactiveCognition
8
+ include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers) &&
9
+ Legion::Extensions::Helpers.const_defined?(:Lex)
10
+
11
+ def create_sensorimotor_coupling(action:, perception:, domain:, loop_type: :sensorimotor, **)
12
+ type = loop_type.is_a?(Symbol) ? loop_type : loop_type.to_sym
13
+ loop = enaction_engine.create_coupling(
14
+ action: action,
15
+ perception: perception,
16
+ domain: domain,
17
+ loop_type: type
18
+ )
19
+ Legion::Logging.debug "[enactive_cognition] created coupling id=#{loop.id[0..7]} " \
20
+ "action=#{action} domain=#{domain} type=#{type}"
21
+ { success: true, coupling: loop.to_h }
22
+ end
23
+
24
+ def execute_enactive_action(coupling_id:, actual_perception:, **)
25
+ result = enaction_engine.execute_action(
26
+ coupling_id: coupling_id,
27
+ actual_perception: actual_perception
28
+ )
29
+ unless result[:success]
30
+ Legion::Logging.debug "[enactive_cognition] execute failed: #{coupling_id[0..7]} not found"
31
+ return result
32
+ end
33
+
34
+ Legion::Logging.debug "[enactive_cognition] executed #{coupling_id[0..7]} " \
35
+ "match=#{result[:match]} label=#{result[:coupling_label]}"
36
+ result
37
+ end
38
+
39
+ def adapt_sensorimotor_coupling(coupling_id:, new_perception:, **)
40
+ result = enaction_engine.adapt_coupling(
41
+ coupling_id: coupling_id,
42
+ new_perception: new_perception
43
+ )
44
+ unless result[:success]
45
+ Legion::Logging.debug "[enactive_cognition] adapt failed: #{coupling_id[0..7]} not found"
46
+ return result
47
+ end
48
+
49
+ Legion::Logging.info "[enactive_cognition] adapted #{coupling_id[0..7]} new_perception=#{new_perception}"
50
+ result
51
+ end
52
+
53
+ def find_action_for_perception(perception:, **)
54
+ loop = enaction_engine.find_action_for(perception: perception)
55
+ unless loop
56
+ Legion::Logging.debug "[enactive_cognition] no coupled action found for perception=#{perception}"
57
+ return { found: false, perception: perception }
58
+ end
59
+
60
+ Legion::Logging.debug "[enactive_cognition] found action=#{loop.action} for perception=#{perception}"
61
+ { found: true, action: loop.action, coupling: loop.to_h }
62
+ end
63
+
64
+ def coupled_sensorimotor_loops(**)
65
+ loops = enaction_engine.coupled_loops
66
+ Legion::Logging.debug "[enactive_cognition] coupled loops count=#{loops.size}"
67
+ { loops: loops.map(&:to_h), count: loops.size }
68
+ end
69
+
70
+ def domain_couplings(domain:, **)
71
+ loops = enaction_engine.by_domain(domain: domain)
72
+ Legion::Logging.debug "[enactive_cognition] domain=#{domain} loops=#{loops.size}"
73
+ { domain: domain, loops: loops.map(&:to_h), count: loops.size }
74
+ end
75
+
76
+ def strongest_couplings(limit: 5, **)
77
+ loops = enaction_engine.strongest_couplings(limit: limit)
78
+ Legion::Logging.debug "[enactive_cognition] strongest couplings limit=#{limit} found=#{loops.size}"
79
+ { loops: loops.map(&:to_h), count: loops.size }
80
+ end
81
+
82
+ def overall_enactive_coupling(**)
83
+ strength = enaction_engine.overall_coupling
84
+ label = coupling_label_for(strength)
85
+ Legion::Logging.debug "[enactive_cognition] overall_coupling=#{strength.round(3)} label=#{label}"
86
+ { overall_coupling: strength, coupling_label: label }
87
+ end
88
+
89
+ def update_enactive_cognition(decay: false, prune: false, **)
90
+ enaction_engine.decay_all if decay
91
+ enaction_engine.prune_decoupled if prune
92
+ Legion::Logging.debug "[enactive_cognition] update decay=#{decay} prune=#{prune} " \
93
+ "remaining=#{enaction_engine.count}"
94
+ { success: true, decay: decay, prune: prune, coupling_count: enaction_engine.count }
95
+ end
96
+
97
+ def enactive_cognition_stats(**)
98
+ stats = enaction_engine.to_h
99
+ Legion::Logging.debug "[enactive_cognition] stats couplings=#{stats[:coupling_count]} " \
100
+ "coupled=#{stats[:coupled_count]}"
101
+ { success: true, stats: stats }
102
+ end
103
+
104
+ private
105
+
106
+ def enaction_engine
107
+ @enaction_engine ||= Helpers::EnactionEngine.new
108
+ end
109
+
110
+ def coupling_label_for(strength)
111
+ Helpers::SensorimotorLoop::COUPLING_LABELS.each do |range, label|
112
+ return label if range.cover?(strength)
113
+ end
114
+ :decoupled
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module EnactiveCognition
6
+ VERSION = '0.1.0'
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/enactive_cognition/version'
4
+ require 'legion/extensions/enactive_cognition/helpers/sensorimotor_loop'
5
+ require 'legion/extensions/enactive_cognition/helpers/enaction_engine'
6
+ require 'legion/extensions/enactive_cognition/runners/enactive_cognition'
7
+
8
+ module Legion
9
+ module Extensions
10
+ module EnactiveCognition
11
+ extend Legion::Extensions::Core if Legion::Extensions.const_defined? :Core
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/enactive_cognition/client'
4
+
5
+ RSpec.describe Legion::Extensions::EnactiveCognition::Client do
6
+ it 'responds to all runner methods' do
7
+ client = described_class.new
8
+ expect(client).to respond_to(:create_sensorimotor_coupling)
9
+ expect(client).to respond_to(:execute_enactive_action)
10
+ expect(client).to respond_to(:adapt_sensorimotor_coupling)
11
+ expect(client).to respond_to(:find_action_for_perception)
12
+ expect(client).to respond_to(:coupled_sensorimotor_loops)
13
+ expect(client).to respond_to(:domain_couplings)
14
+ expect(client).to respond_to(:strongest_couplings)
15
+ expect(client).to respond_to(:overall_enactive_coupling)
16
+ expect(client).to respond_to(:update_enactive_cognition)
17
+ expect(client).to respond_to(:enactive_cognition_stats)
18
+ end
19
+ end
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/enactive_cognition/helpers/sensorimotor_loop'
4
+ require 'legion/extensions/enactive_cognition/helpers/enaction_engine'
5
+
6
+ RSpec.describe Legion::Extensions::EnactiveCognition::Helpers::EnactionEngine do
7
+ subject(:engine) { described_class.new }
8
+
9
+ let(:coupling) do
10
+ engine.create_coupling(action: 'grasp', perception: 'grip', domain: 'motor')
11
+ end
12
+
13
+ describe '#create_coupling' do
14
+ it 'returns a SensorimotorLoop instance' do
15
+ expect(coupling).to be_a(Legion::Extensions::EnactiveCognition::Helpers::SensorimotorLoop)
16
+ end
17
+
18
+ it 'stores the coupling by id' do
19
+ expect(engine.couplings[coupling.id]).to eq(coupling)
20
+ end
21
+
22
+ it 'increments coupling count' do
23
+ engine.create_coupling(action: 'push', perception: 'moved', domain: 'motor')
24
+ expect(engine.count).to eq(1)
25
+ end
26
+
27
+ it 'defaults loop_type to :sensorimotor' do
28
+ expect(coupling.loop_type).to eq(:sensorimotor)
29
+ end
30
+
31
+ it 'accepts explicit loop_type' do
32
+ lp = engine.create_coupling(action: 'think', perception: 'clarity', domain: 'cog', loop_type: :cognitive)
33
+ expect(lp.loop_type).to eq(:cognitive)
34
+ end
35
+ end
36
+
37
+ describe '#execute_action' do
38
+ it 'returns success: true on match' do
39
+ result = engine.execute_action(coupling_id: coupling.id, actual_perception: 'grip')
40
+ expect(result[:success]).to be true
41
+ expect(result[:match]).to be true
42
+ end
43
+
44
+ it 'returns success: true on mismatch' do
45
+ result = engine.execute_action(coupling_id: coupling.id, actual_perception: 'slip')
46
+ expect(result[:success]).to be true
47
+ expect(result[:match]).to be false
48
+ end
49
+
50
+ it 'returns success: false for missing coupling' do
51
+ result = engine.execute_action(coupling_id: 'nonexistent', actual_perception: 'grip')
52
+ expect(result[:success]).to be false
53
+ expect(result[:reason]).to eq(:not_found)
54
+ end
55
+
56
+ it 'includes coupling_label in result' do
57
+ result = engine.execute_action(coupling_id: coupling.id, actual_perception: 'grip')
58
+ expect(result[:coupling_label]).to be_a(Symbol)
59
+ end
60
+ end
61
+
62
+ describe '#adapt_coupling' do
63
+ it 'updates the coupling perception' do
64
+ engine.adapt_coupling(coupling_id: coupling.id, new_perception: 'firm_grip')
65
+ expect(engine.couplings[coupling.id].perception).to eq('firm_grip')
66
+ end
67
+
68
+ it 'returns success: true' do
69
+ result = engine.adapt_coupling(coupling_id: coupling.id, new_perception: 'firm_grip')
70
+ expect(result[:success]).to be true
71
+ end
72
+
73
+ it 'returns success: false for missing coupling' do
74
+ result = engine.adapt_coupling(coupling_id: 'bad', new_perception: 'x')
75
+ expect(result[:success]).to be false
76
+ end
77
+ end
78
+
79
+ describe '#find_action_for' do
80
+ before do
81
+ 5.times { engine.execute_action(coupling_id: coupling.id, actual_perception: 'grip') }
82
+ end
83
+
84
+ it 'returns the loop when it is coupled and perception matches' do
85
+ result = engine.find_action_for(perception: 'grip')
86
+ if coupling.coupled?
87
+ expect(result).not_to be_nil
88
+ expect(result.action).to eq('grasp')
89
+ else
90
+ expect(result).to be_nil
91
+ end
92
+ end
93
+
94
+ it 'returns nil when perception does not match any coupled loop' do
95
+ result = engine.find_action_for(perception: 'unknown_perception')
96
+ expect(result).to be_nil
97
+ end
98
+ end
99
+
100
+ describe '#coupled_loops' do
101
+ it 'returns empty when nothing is coupled' do
102
+ engine.create_coupling(action: 'act', perception: 'per', domain: 'dom')
103
+ expect(engine.coupled_loops).to be_empty
104
+ end
105
+
106
+ it 'returns loops above coupling threshold' do
107
+ c2 = engine.create_coupling(action: 'act2', perception: 'per2', domain: 'dom2')
108
+ allow(c2).to receive(:coupled?).and_return(true)
109
+ expect(engine.coupled_loops).to include(c2)
110
+ end
111
+ end
112
+
113
+ describe '#by_domain' do
114
+ it 'returns loops in the specified domain' do
115
+ engine.create_coupling(action: 'act', perception: 'per', domain: 'motor')
116
+ engine.create_coupling(action: 'speak', perception: 'heard', domain: 'social')
117
+ expect(engine.by_domain(domain: 'motor').size).to eq(1)
118
+ end
119
+ end
120
+
121
+ describe '#by_type' do
122
+ it 'returns loops of the specified type' do
123
+ engine.create_coupling(action: 'act', perception: 'per', domain: 'dom', loop_type: :cognitive)
124
+ engine.create_coupling(action: 'act2', perception: 'per2', domain: 'dom2', loop_type: :social)
125
+ expect(engine.by_type(loop_type: :cognitive).size).to eq(1)
126
+ end
127
+ end
128
+
129
+ describe '#strongest_couplings' do
130
+ it 'returns at most limit couplings sorted by strength desc' do
131
+ 3.times { |i| engine.create_coupling(action: "a#{i}", perception: "p#{i}", domain: 'dom') }
132
+ result = engine.strongest_couplings(limit: 2)
133
+ expect(result.size).to be <= 2
134
+ end
135
+ end
136
+
137
+ describe '#overall_coupling' do
138
+ it 'returns 0.0 when no couplings exist' do
139
+ expect(engine.overall_coupling).to eq(0.0)
140
+ end
141
+
142
+ it 'returns mean coupling strength' do
143
+ engine.create_coupling(action: 'a', perception: 'p', domain: 'd')
144
+ expect(engine.overall_coupling).to eq(
145
+ Legion::Extensions::EnactiveCognition::Helpers::SensorimotorLoop::DEFAULT_COUPLING_STRENGTH
146
+ )
147
+ end
148
+ end
149
+
150
+ describe '#decay_all' do
151
+ it 'reduces coupling strength of all loops' do
152
+ lp = engine.create_coupling(action: 'a', perception: 'p', domain: 'd')
153
+ before = lp.coupling_strength
154
+ engine.decay_all
155
+ expect(lp.coupling_strength).to be < before
156
+ end
157
+ end
158
+
159
+ describe '#prune_decoupled' do
160
+ it 'removes very weak couplings' do
161
+ lp = engine.create_coupling(action: 'a', perception: 'p', domain: 'd')
162
+ 100.times { lp.execute!(actual_perception: 'miss') }
163
+ count_before = engine.count
164
+ engine.prune_decoupled
165
+ expect(engine.count).to be <= count_before
166
+ end
167
+ end
168
+
169
+ describe '#to_h' do
170
+ it 'includes expected keys' do
171
+ result = engine.to_h
172
+ expect(result).to include(:coupling_count, :coupled_count, :overall_coupling, :strongest)
173
+ end
174
+
175
+ it 'rounds overall_coupling to 4 decimal places' do
176
+ engine.create_coupling(action: 'a', perception: 'p', domain: 'd')
177
+ result = engine.to_h
178
+ expect(result[:overall_coupling]).to be_a(Float)
179
+ end
180
+ end
181
+ end
@@ -0,0 +1,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/enactive_cognition/helpers/sensorimotor_loop'
4
+
5
+ RSpec.describe Legion::Extensions::EnactiveCognition::Helpers::SensorimotorLoop do
6
+ subject(:loop_obj) do
7
+ described_class.new(action: 'reach', perception: 'contact', domain: 'motor')
8
+ end
9
+
10
+ describe '#initialize' do
11
+ it 'assigns a uuid id' do
12
+ expect(loop_obj.id).to match(/\A[0-9a-f-]{36}\z/)
13
+ end
14
+
15
+ it 'sets default coupling strength' do
16
+ expect(loop_obj.coupling_strength).to eq(described_class::DEFAULT_COUPLING_STRENGTH)
17
+ end
18
+
19
+ it 'defaults loop_type to :sensorimotor' do
20
+ expect(loop_obj.loop_type).to eq(:sensorimotor)
21
+ end
22
+
23
+ it 'accepts valid loop types' do
24
+ cognitive = described_class.new(action: 'think', perception: 'insight', domain: 'cog', loop_type: :cognitive)
25
+ expect(cognitive.loop_type).to eq(:cognitive)
26
+ end
27
+
28
+ it 'falls back to :sensorimotor for invalid loop_type' do
29
+ bad = described_class.new(action: 'x', perception: 'y', domain: 'z', loop_type: :invalid)
30
+ expect(bad.loop_type).to eq(:sensorimotor)
31
+ end
32
+
33
+ it 'starts with zero execution count' do
34
+ expect(loop_obj.execution_count).to eq(0)
35
+ end
36
+
37
+ it 'starts with zero accurate_predictions' do
38
+ expect(loop_obj.accurate_predictions).to eq(0)
39
+ end
40
+ end
41
+
42
+ describe '#execute!' do
43
+ context 'when actual matches expected perception' do
44
+ it 'increments execution_count' do
45
+ loop_obj.execute!(actual_perception: 'contact')
46
+ expect(loop_obj.execution_count).to eq(1)
47
+ end
48
+
49
+ it 'increments accurate_predictions' do
50
+ loop_obj.execute!(actual_perception: 'contact')
51
+ expect(loop_obj.accurate_predictions).to eq(1)
52
+ end
53
+
54
+ it 'strengthens coupling' do
55
+ before = loop_obj.coupling_strength
56
+ loop_obj.execute!(actual_perception: 'contact')
57
+ expect(loop_obj.coupling_strength).to be > before
58
+ end
59
+
60
+ it 'returns match: true' do
61
+ result = loop_obj.execute!(actual_perception: 'contact')
62
+ expect(result[:match]).to be true
63
+ end
64
+
65
+ it 'does not exceed COUPLING_CEILING' do
66
+ 20.times { loop_obj.execute!(actual_perception: 'contact') }
67
+ expect(loop_obj.coupling_strength).to be <= described_class::COUPLING_CEILING
68
+ end
69
+ end
70
+
71
+ context 'when actual does not match expected perception' do
72
+ it 'weakens coupling' do
73
+ before = loop_obj.coupling_strength
74
+ loop_obj.execute!(actual_perception: 'no_contact')
75
+ expect(loop_obj.coupling_strength).to be < before
76
+ end
77
+
78
+ it 'returns match: false' do
79
+ result = loop_obj.execute!(actual_perception: 'no_contact')
80
+ expect(result[:match]).to be false
81
+ end
82
+
83
+ it 'does not fall below COUPLING_FLOOR' do
84
+ 20.times { loop_obj.execute!(actual_perception: 'no_contact') }
85
+ expect(loop_obj.coupling_strength).to be >= described_class::COUPLING_FLOOR
86
+ end
87
+
88
+ it 'does not increment accurate_predictions' do
89
+ loop_obj.execute!(actual_perception: 'no_contact')
90
+ expect(loop_obj.accurate_predictions).to eq(0)
91
+ end
92
+ end
93
+
94
+ it 'updates prediction_accuracy' do
95
+ 3.times { loop_obj.execute!(actual_perception: 'contact') }
96
+ loop_obj.execute!(actual_perception: 'miss')
97
+ expect(loop_obj.prediction_accuracy).to eq(0.75)
98
+ end
99
+
100
+ it 'sets last_executed_at' do
101
+ loop_obj.execute!(actual_perception: 'contact')
102
+ expect(loop_obj.last_executed_at).not_to be_nil
103
+ end
104
+ end
105
+
106
+ describe '#coupled?' do
107
+ it 'returns false at default strength (0.5)' do
108
+ expect(loop_obj.coupled?).to be false
109
+ end
110
+
111
+ it 'returns true when strength reaches 0.6' do
112
+ loop_obj.execute!(actual_perception: 'contact')
113
+ loop_obj.execute!(actual_perception: 'contact')
114
+ if loop_obj.coupling_strength >= 0.6
115
+ expect(loop_obj.coupled?).to be true
116
+ else
117
+ expect(loop_obj.coupled?).to be false
118
+ end
119
+ end
120
+ end
121
+
122
+ describe '#coupling_label' do
123
+ it 'returns :forming at default strength 0.5' do
124
+ expect(loop_obj.coupling_label).to eq(:forming)
125
+ end
126
+
127
+ it 'returns :entrained at strength 0.9' do
128
+ allow(loop_obj).to receive(:coupling_strength).and_return(0.9)
129
+ expect(loop_obj.coupling_label).to eq(:entrained)
130
+ end
131
+
132
+ it 'returns :decoupled at strength 0.1' do
133
+ allow(loop_obj).to receive(:coupling_strength).and_return(0.1)
134
+ expect(loop_obj.coupling_label).to eq(:decoupled)
135
+ end
136
+
137
+ it 'returns :weak at strength 0.25' do
138
+ allow(loop_obj).to receive(:coupling_strength).and_return(0.25)
139
+ expect(loop_obj.coupling_label).to eq(:weak)
140
+ end
141
+
142
+ it 'returns :coupled at strength 0.7' do
143
+ allow(loop_obj).to receive(:coupling_strength).and_return(0.7)
144
+ expect(loop_obj.coupling_label).to eq(:coupled)
145
+ end
146
+ end
147
+
148
+ describe '#adapt_perception!' do
149
+ it 'updates the expected perception' do
150
+ loop_obj.adapt_perception!(new_perception: 'soft_contact')
151
+ expect(loop_obj.perception).to eq('soft_contact')
152
+ end
153
+ end
154
+
155
+ describe '#decay!' do
156
+ it 'reduces coupling_strength by COUPLING_DECAY' do
157
+ before = loop_obj.coupling_strength
158
+ loop_obj.decay!
159
+ expect(loop_obj.coupling_strength).to eq((before - described_class::COUPLING_DECAY).clamp(0.0, 1.0))
160
+ end
161
+
162
+ it 'does not fall below COUPLING_FLOOR' do
163
+ 100.times { loop_obj.decay! }
164
+ expect(loop_obj.coupling_strength).to be >= described_class::COUPLING_FLOOR
165
+ end
166
+ end
167
+
168
+ describe '#to_h' do
169
+ it 'includes all expected keys' do
170
+ hash = loop_obj.to_h
171
+ expect(hash).to include(
172
+ :id, :action, :perception, :domain, :loop_type,
173
+ :coupling_strength, :coupling_label, :prediction_accuracy,
174
+ :execution_count, :accurate_predictions, :coupled,
175
+ :created_at, :last_executed_at
176
+ )
177
+ end
178
+
179
+ it 'reflects current coupling_label' do
180
+ hash = loop_obj.to_h
181
+ expect(hash[:coupling_label]).to eq(loop_obj.coupling_label)
182
+ end
183
+ end
184
+ end
@@ -0,0 +1,214 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/enactive_cognition/client'
4
+
5
+ RSpec.describe Legion::Extensions::EnactiveCognition::Runners::EnactiveCognition do
6
+ let(:client) { Legion::Extensions::EnactiveCognition::Client.new }
7
+
8
+ def make_coupling(action: 'press', perception: 'click', domain: 'motor', loop_type: :sensorimotor)
9
+ client.create_sensorimotor_coupling(
10
+ action: action,
11
+ perception: perception,
12
+ domain: domain,
13
+ loop_type: loop_type
14
+ )
15
+ end
16
+
17
+ describe '#create_sensorimotor_coupling' do
18
+ it 'returns success: true' do
19
+ result = make_coupling
20
+ expect(result[:success]).to be true
21
+ end
22
+
23
+ it 'includes coupling hash with uuid id' do
24
+ result = make_coupling
25
+ expect(result[:coupling][:id]).to match(/\A[0-9a-f-]{36}\z/)
26
+ end
27
+
28
+ it 'reflects the given action and perception' do
29
+ result = make_coupling(action: 'push', perception: 'moved')
30
+ expect(result[:coupling][:action]).to eq('push')
31
+ expect(result[:coupling][:perception]).to eq('moved')
32
+ end
33
+
34
+ it 'accepts string loop_type and converts to symbol' do
35
+ result = client.create_sensorimotor_coupling(
36
+ action: 'x',
37
+ perception: 'y',
38
+ domain: 'z',
39
+ loop_type: 'cognitive'
40
+ )
41
+ expect(result[:coupling][:loop_type]).to eq(:cognitive)
42
+ end
43
+ end
44
+
45
+ describe '#execute_enactive_action' do
46
+ let(:coupling_id) { make_coupling[:coupling][:id] }
47
+
48
+ it 'returns success: true on matching perception' do
49
+ result = client.execute_enactive_action(coupling_id: coupling_id, actual_perception: 'click')
50
+ expect(result[:success]).to be true
51
+ expect(result[:match]).to be true
52
+ end
53
+
54
+ it 'returns success: true on mismatching perception' do
55
+ result = client.execute_enactive_action(coupling_id: coupling_id, actual_perception: 'silence')
56
+ expect(result[:success]).to be true
57
+ expect(result[:match]).to be false
58
+ end
59
+
60
+ it 'returns success: false for unknown coupling_id' do
61
+ result = client.execute_enactive_action(coupling_id: 'bad-id', actual_perception: 'click')
62
+ expect(result[:success]).to be false
63
+ end
64
+
65
+ it 'includes coupling_label in result' do
66
+ result = client.execute_enactive_action(coupling_id: coupling_id, actual_perception: 'click')
67
+ expect(result[:coupling_label]).to be_a(Symbol)
68
+ end
69
+ end
70
+
71
+ describe '#adapt_sensorimotor_coupling' do
72
+ let(:coupling_id) { make_coupling[:coupling][:id] }
73
+
74
+ it 'returns success: true' do
75
+ result = client.adapt_sensorimotor_coupling(coupling_id: coupling_id, new_perception: 'soft_click')
76
+ expect(result[:success]).to be true
77
+ end
78
+
79
+ it 'returns new_perception in result' do
80
+ result = client.adapt_sensorimotor_coupling(coupling_id: coupling_id, new_perception: 'soft_click')
81
+ expect(result[:new_perception]).to eq('soft_click')
82
+ end
83
+
84
+ it 'returns success: false for missing coupling' do
85
+ result = client.adapt_sensorimotor_coupling(coupling_id: 'missing', new_perception: 'x')
86
+ expect(result[:success]).to be false
87
+ end
88
+ end
89
+
90
+ describe '#find_action_for_perception' do
91
+ it 'returns found: false when nothing is coupled' do
92
+ result = client.find_action_for_perception(perception: 'click')
93
+ expect(result[:found]).to be false
94
+ end
95
+
96
+ it 'returns found: true when a coupled loop matches' do
97
+ id = make_coupling[:coupling][:id]
98
+ 5.times { client.execute_enactive_action(coupling_id: id, actual_perception: 'click') }
99
+ result = client.find_action_for_perception(perception: 'click')
100
+ # may or may not be coupled depending on starting strength + reinforcement
101
+ if result[:found]
102
+ expect(result[:action]).to eq('press')
103
+ else
104
+ expect(result[:found]).to be false
105
+ end
106
+ end
107
+ end
108
+
109
+ describe '#coupled_sensorimotor_loops' do
110
+ it 'returns loops and count' do
111
+ result = client.coupled_sensorimotor_loops
112
+ expect(result).to include(:loops, :count)
113
+ expect(result[:loops]).to be_an(Array)
114
+ end
115
+
116
+ it 'count is zero initially' do
117
+ expect(client.coupled_sensorimotor_loops[:count]).to eq(0)
118
+ end
119
+ end
120
+
121
+ describe '#domain_couplings' do
122
+ before { make_coupling(domain: 'motor') }
123
+
124
+ it 'returns loops in the domain' do
125
+ result = client.domain_couplings(domain: 'motor')
126
+ expect(result[:domain]).to eq('motor')
127
+ expect(result[:count]).to eq(1)
128
+ end
129
+
130
+ it 'returns zero for unknown domain' do
131
+ result = client.domain_couplings(domain: 'unknown')
132
+ expect(result[:count]).to eq(0)
133
+ end
134
+ end
135
+
136
+ describe '#strongest_couplings' do
137
+ before { 3.times { |i| make_coupling(action: "act#{i}", perception: "per#{i}", domain: 'dom') } }
138
+
139
+ it 'returns loops array' do
140
+ result = client.strongest_couplings(limit: 2)
141
+ expect(result[:loops].size).to be <= 2
142
+ end
143
+
144
+ it 'uses default limit of 5' do
145
+ result = client.strongest_couplings
146
+ expect(result[:loops].size).to be <= 5
147
+ end
148
+ end
149
+
150
+ describe '#overall_enactive_coupling' do
151
+ it 'returns overall_coupling and coupling_label' do
152
+ result = client.overall_enactive_coupling
153
+ expect(result).to include(:overall_coupling, :coupling_label)
154
+ end
155
+
156
+ it 'returns 0.0 when no couplings exist' do
157
+ expect(client.overall_enactive_coupling[:overall_coupling]).to eq(0.0)
158
+ end
159
+
160
+ it 'returns :decoupled label when no couplings' do
161
+ expect(client.overall_enactive_coupling[:coupling_label]).to eq(:decoupled)
162
+ end
163
+
164
+ it 'returns :forming label at default strength' do
165
+ make_coupling
166
+ result = client.overall_enactive_coupling
167
+ expect(result[:coupling_label]).to eq(:forming)
168
+ end
169
+ end
170
+
171
+ describe '#update_enactive_cognition' do
172
+ before { make_coupling }
173
+
174
+ it 'returns success: true' do
175
+ result = client.update_enactive_cognition
176
+ expect(result[:success]).to be true
177
+ end
178
+
179
+ it 'decays couplings when decay: true' do
180
+ before_strength = client.strongest_couplings(limit: 1)[:loops].first[:coupling_strength]
181
+ client.update_enactive_cognition(decay: true)
182
+ after_strength = client.strongest_couplings(limit: 1)[:loops].first[:coupling_strength]
183
+ expect(after_strength).to be < before_strength
184
+ end
185
+
186
+ it 'reflects coupling_count in result' do
187
+ result = client.update_enactive_cognition
188
+ expect(result[:coupling_count]).to be >= 1
189
+ end
190
+ end
191
+
192
+ describe '#enactive_cognition_stats' do
193
+ it 'returns success: true' do
194
+ result = client.enactive_cognition_stats
195
+ expect(result[:success]).to be true
196
+ end
197
+
198
+ it 'includes stats hash' do
199
+ result = client.enactive_cognition_stats
200
+ expect(result[:stats]).to include(:coupling_count, :coupled_count, :overall_coupling)
201
+ end
202
+
203
+ it 'reports zero coupling_count when empty' do
204
+ result = client.enactive_cognition_stats
205
+ expect(result[:stats][:coupling_count]).to eq(0)
206
+ end
207
+
208
+ it 'increments coupling_count after creating a coupling' do
209
+ make_coupling
210
+ result = client.enactive_cognition_stats
211
+ expect(result[:stats][:coupling_count]).to eq(1)
212
+ end
213
+ end
214
+ end
@@ -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/enactive_cognition'
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,74 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: lex-enactive-cognition
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: 'Varela''s enactivism: cognition through action-perception loops, sensorimotor
27
+ contingencies, and structural coupling'
28
+ email:
29
+ - matthewdiverson@gmail.com
30
+ executables: []
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - Gemfile
35
+ - lex-enactive-cognition.gemspec
36
+ - lib/legion/extensions/enactive_cognition.rb
37
+ - lib/legion/extensions/enactive_cognition/client.rb
38
+ - lib/legion/extensions/enactive_cognition/helpers/enaction_engine.rb
39
+ - lib/legion/extensions/enactive_cognition/helpers/sensorimotor_loop.rb
40
+ - lib/legion/extensions/enactive_cognition/runners/enactive_cognition.rb
41
+ - lib/legion/extensions/enactive_cognition/version.rb
42
+ - spec/legion/extensions/enactive_cognition/client_spec.rb
43
+ - spec/legion/extensions/enactive_cognition/helpers/enaction_engine_spec.rb
44
+ - spec/legion/extensions/enactive_cognition/helpers/sensorimotor_loop_spec.rb
45
+ - spec/legion/extensions/enactive_cognition/runners/enactive_cognition_spec.rb
46
+ - spec/spec_helper.rb
47
+ homepage: https://github.com/LegionIO/lex-enactive-cognition
48
+ licenses:
49
+ - MIT
50
+ metadata:
51
+ homepage_uri: https://github.com/LegionIO/lex-enactive-cognition
52
+ source_code_uri: https://github.com/LegionIO/lex-enactive-cognition
53
+ documentation_uri: https://github.com/LegionIO/lex-enactive-cognition
54
+ changelog_uri: https://github.com/LegionIO/lex-enactive-cognition
55
+ bug_tracker_uri: https://github.com/LegionIO/lex-enactive-cognition/issues
56
+ rubygems_mfa_required: 'true'
57
+ rdoc_options: []
58
+ require_paths:
59
+ - lib
60
+ required_ruby_version: !ruby/object:Gem::Requirement
61
+ requirements:
62
+ - - ">="
63
+ - !ruby/object:Gem::Version
64
+ version: '3.4'
65
+ required_rubygems_version: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - ">="
68
+ - !ruby/object:Gem::Version
69
+ version: '0'
70
+ requirements: []
71
+ rubygems_version: 3.6.9
72
+ specification_version: 4
73
+ summary: LEX Enactive Cognition
74
+ test_files: []