lex-hebbian-assembly 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: a7a9d7d72a5ff5d0f18795d1429a8db621b543021e9ccc316027110529a6f235
4
+ data.tar.gz: 30287fe026d3c978361f85bf5e43757946f0c1bb4ee28bb7472a7ae11d9df70a
5
+ SHA512:
6
+ metadata.gz: fed78e7e6d176dad68a17b9239311bf932c7e1160978b511557e9836b66afea52a10d2ce57427d3608bd60a59a67d3595d5c15df59fe0fa29103b8fa9ca53a55
7
+ data.tar.gz: e00aa1d2f97b2c87e14db0109b1f6a0500f6b043793c7b587a53c18daa2e32ea31c04d050955d922fda134373ce27dc1baa58f735523daed59034cd8629e755b
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,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'lib/legion/extensions/hebbian_assembly/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'lex-hebbian-assembly'
7
+ spec.version = Legion::Extensions::HebbianAssembly::VERSION
8
+ spec.authors = ['Esity']
9
+ spec.email = ['matthewdiverson@gmail.com']
10
+
11
+ spec.summary = 'LEX Hebbian Assembly'
12
+ spec.description = "Hebb's Cell Assembly Theory for brain-modeled agentic AI — neurons that fire " \
13
+ 'together wire together, forming emergent assemblies that represent concepts, ' \
14
+ 'enable pattern completion, and support associative recall through ' \
15
+ 'co-activation-driven synaptic strengthening.'
16
+ spec.homepage = 'https://github.com/LegionIO/lex-hebbian-assembly'
17
+ spec.license = 'MIT'
18
+ spec.required_ruby_version = '>= 3.4'
19
+
20
+ spec.metadata['homepage_uri'] = spec.homepage
21
+ spec.metadata['source_code_uri'] = 'https://github.com/LegionIO/lex-hebbian-assembly'
22
+ spec.metadata['documentation_uri'] = 'https://github.com/LegionIO/lex-hebbian-assembly'
23
+ spec.metadata['changelog_uri'] = 'https://github.com/LegionIO/lex-hebbian-assembly'
24
+ spec.metadata['bug_tracker_uri'] = 'https://github.com/LegionIO/lex-hebbian-assembly/issues'
25
+ spec.metadata['rubygems_mfa_required'] = 'true'
26
+
27
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
28
+ Dir.glob('{lib,spec}/**/*') + %w[lex-hebbian-assembly.gemspec Gemfile]
29
+ end
30
+ spec.require_paths = ['lib']
31
+ spec.add_development_dependency 'legion-gaia'
32
+ 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 HebbianAssembly
8
+ module Actor
9
+ class Decay < Legion::Extensions::Actors::Every
10
+ def runner_class
11
+ Legion::Extensions::HebbianAssembly::Runners::HebbianAssembly
12
+ end
13
+
14
+ def runner_function
15
+ 'update_hebbian'
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,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/hebbian_assembly/helpers/constants'
4
+ require 'legion/extensions/hebbian_assembly/helpers/unit'
5
+ require 'legion/extensions/hebbian_assembly/helpers/assembly'
6
+ require 'legion/extensions/hebbian_assembly/helpers/assembly_network'
7
+ require 'legion/extensions/hebbian_assembly/runners/hebbian_assembly'
8
+
9
+ module Legion
10
+ module Extensions
11
+ module HebbianAssembly
12
+ class Client
13
+ include Runners::HebbianAssembly
14
+
15
+ def initialize(network: nil, **)
16
+ @network = network || Helpers::AssemblyNetwork.new
17
+ end
18
+
19
+ private
20
+
21
+ attr_reader :network
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module HebbianAssembly
6
+ module Helpers
7
+ class Assembly
8
+ include Constants
9
+
10
+ attr_reader :id, :member_ids, :formed_at, :activation_count, :last_activated
11
+ attr_accessor :coherence
12
+
13
+ def initialize(id:, member_ids:)
14
+ @id = id
15
+ @member_ids = Array(member_ids).uniq
16
+ @coherence = 0.5
17
+ @formed_at = Time.now.utc
18
+ @activation_count = 0
19
+ @last_activated = nil
20
+ end
21
+
22
+ def activate
23
+ @activation_count += 1
24
+ @last_activated = Time.now.utc
25
+ end
26
+
27
+ def consolidate
28
+ @coherence = [@coherence + CONSOLIDATION_BOOST, MAX_WEIGHT].min
29
+ end
30
+
31
+ def decay
32
+ @coherence = [@coherence - WEIGHT_DECAY, 0.0].max
33
+ end
34
+
35
+ def dissolving?
36
+ @coherence < ASSEMBLY_MIN_WEIGHT
37
+ end
38
+
39
+ def member_count
40
+ @member_ids.size
41
+ end
42
+
43
+ def includes?(unit_id)
44
+ @member_ids.include?(unit_id)
45
+ end
46
+
47
+ def state
48
+ if @last_activated && (Time.now.utc - @last_activated) < CO_ACTIVATION_WINDOW
49
+ :active
50
+ elsif @last_activated && (Time.now.utc - @last_activated) < (CO_ACTIVATION_WINDOW * 3)
51
+ :primed
52
+ elsif dissolving?
53
+ :dissolving
54
+ elsif @coherence >= ASSEMBLY_MIN_WEIGHT
55
+ :dormant
56
+ else
57
+ :forming
58
+ end
59
+ end
60
+
61
+ def to_h
62
+ {
63
+ id: @id,
64
+ members: @member_ids.dup,
65
+ member_count: member_count,
66
+ coherence: @coherence.round(4),
67
+ state: state,
68
+ state_label: ASSEMBLY_STATE_LABELS[state],
69
+ activation_count: @activation_count,
70
+ formed_at: @formed_at,
71
+ last_activated: @last_activated
72
+ }
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,186 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module HebbianAssembly
6
+ module Helpers
7
+ class AssemblyNetwork
8
+ include Constants
9
+
10
+ attr_reader :units, :assemblies, :activation_history
11
+
12
+ def initialize
13
+ @units = {}
14
+ @assemblies = {}
15
+ @activation_history = []
16
+ @assembly_counter = 0
17
+ end
18
+
19
+ def add_unit(id:, domain: :general)
20
+ return @units[id] if @units.key?(id)
21
+ return nil if @units.size >= MAX_UNITS
22
+
23
+ @units[id] = Unit.new(id: id, domain: domain)
24
+ end
25
+
26
+ def activate_unit(id:, level: 1.0)
27
+ ensure_unit(id)
28
+ unit = @units[id]
29
+ unit.activate(level: level)
30
+ record_activation(id)
31
+ hebbian_update(id)
32
+ detect_assemblies
33
+ unit
34
+ end
35
+
36
+ def co_activate(ids:, level: 1.0)
37
+ ids = Array(ids)
38
+ ids.each do |id|
39
+ ensure_unit(id)
40
+ @units[id].activate(level: level)
41
+ record_activation(id)
42
+ end
43
+
44
+ ids.combination(2).each do |a, b|
45
+ ensure_connection(a, b)
46
+ @units[a].strengthen(b)
47
+ @units[b].strengthen(a)
48
+ end
49
+
50
+ detect_assemblies
51
+ ids.map { |id| @units[id].to_h }
52
+ end
53
+
54
+ def query_weight(from:, to:)
55
+ return 0.0 unless @units.key?(from)
56
+
57
+ @units[from].weight_to(to)
58
+ end
59
+
60
+ def query_assembly(id:)
61
+ @assemblies[id]
62
+ end
63
+
64
+ def assemblies_containing(unit_id:)
65
+ @assemblies.values.select { |a| a.includes?(unit_id) }
66
+ end
67
+
68
+ def pattern_complete(partial_ids:)
69
+ partial = Array(partial_ids)
70
+ candidates = partial.flat_map { |uid| assemblies_containing(unit_id: uid) }.uniq(&:id)
71
+ return nil if candidates.empty?
72
+
73
+ best = candidates.max_by { |a| (partial & a.member_ids).size }
74
+ missing = best.member_ids - partial
75
+ { assembly_id: best.id, known: partial & best.member_ids, predicted: missing, coherence: best.coherence }
76
+ end
77
+
78
+ def strongest_units(n = 10)
79
+ @units.values
80
+ .sort_by { |u| -u.activation_count }
81
+ .first(n)
82
+ .map(&:to_h)
83
+ end
84
+
85
+ def decay_all
86
+ @units.each_value(&:decay_weights)
87
+ @assemblies.each_value(&:decay)
88
+ @assemblies.reject! { |_, a| a.dissolving? }
89
+ end
90
+
91
+ def unit_count
92
+ @units.size
93
+ end
94
+
95
+ def assembly_count
96
+ @assemblies.size
97
+ end
98
+
99
+ def to_h
100
+ {
101
+ units: @units.size,
102
+ assemblies: @assemblies.size,
103
+ total_connections: @units.values.sum(&:connection_count),
104
+ history_size: @activation_history.size,
105
+ active_units: @units.values.count(&:active?)
106
+ }
107
+ end
108
+
109
+ private
110
+
111
+ def ensure_unit(id)
112
+ add_unit(id: id) unless @units.key?(id)
113
+ end
114
+
115
+ def ensure_connection(a, b)
116
+ @units[a].connect(b) unless @units[a].connections.key?(b)
117
+ @units[b].connect(a) unless @units[b].connections.key?(a)
118
+ end
119
+
120
+ def record_activation(id)
121
+ @activation_history << { unit_id: id, at: Time.now.utc }
122
+ @activation_history.shift while @activation_history.size > MAX_ACTIVATION_HISTORY
123
+ end
124
+
125
+ def hebbian_update(active_id)
126
+ recent_window = Time.now.utc - CO_ACTIVATION_WINDOW
127
+ recent_ids = @activation_history
128
+ .select { |h| h[:at] >= recent_window && h[:unit_id] != active_id }
129
+ .map { |h| h[:unit_id] }
130
+ .uniq
131
+
132
+ recent_ids.each do |other_id|
133
+ next unless @units.key?(other_id) && @units[other_id].active?
134
+
135
+ ensure_connection(active_id, other_id)
136
+ @units[active_id].strengthen(other_id)
137
+ @units[other_id].strengthen(active_id)
138
+ end
139
+ end
140
+
141
+ def detect_assemblies
142
+ strongly_connected = find_strongly_connected
143
+ return if strongly_connected.size < ASSEMBLY_THRESHOLD
144
+
145
+ existing = find_matching_assembly(strongly_connected)
146
+ existing ? reinforce_assembly(existing) : create_assembly(strongly_connected)
147
+ end
148
+
149
+ def find_strongly_connected
150
+ active_ids = @units.values.select(&:active?).map(&:id)
151
+ return [] if active_ids.size < ASSEMBLY_THRESHOLD
152
+
153
+ active_ids.select do |uid|
154
+ peers = active_ids - [uid]
155
+ peers.count { |p| @units[uid].weight_to(p) >= ASSEMBLY_MIN_WEIGHT } >= (ASSEMBLY_THRESHOLD - 1)
156
+ end
157
+ end
158
+
159
+ def find_matching_assembly(member_ids)
160
+ @assemblies.values.find do |a|
161
+ (member_ids - a.member_ids).empty? && (a.member_ids - member_ids).empty?
162
+ end
163
+ end
164
+
165
+ def reinforce_assembly(assembly)
166
+ assembly.activate
167
+ assembly.consolidate
168
+ end
169
+
170
+ def create_assembly(member_ids)
171
+ @assembly_counter += 1
172
+ id = :"assembly_#{@assembly_counter}"
173
+ @assemblies[id] = Assembly.new(id: id, member_ids: member_ids)
174
+ @assemblies[id].activate
175
+ prune_assemblies if @assemblies.size > MAX_ASSEMBLIES
176
+ end
177
+
178
+ def prune_assemblies
179
+ weakest = @assemblies.min_by { |_, a| a.coherence }&.first
180
+ @assemblies.delete(weakest) if weakest
181
+ end
182
+ end
183
+ end
184
+ end
185
+ end
186
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module HebbianAssembly
6
+ module Helpers
7
+ module Constants
8
+ MAX_UNITS = 500
9
+ MAX_ASSEMBLIES = 100
10
+ MAX_CONNECTIONS_PER_UNIT = 30
11
+ MAX_ACTIVATION_HISTORY = 200
12
+
13
+ DEFAULT_WEIGHT = 0.1
14
+ WEIGHT_FLOOR = 0.01
15
+ MAX_WEIGHT = 1.0
16
+ LEARNING_RATE = 0.05
17
+ WEIGHT_DECAY = 0.002
18
+
19
+ ACTIVATION_THRESHOLD = 0.3
20
+ CO_ACTIVATION_WINDOW = 5
21
+ ASSEMBLY_THRESHOLD = 3
22
+ ASSEMBLY_MIN_WEIGHT = 0.3
23
+
24
+ CONSOLIDATION_BOOST = 0.02
25
+ COMPETITION_FACTOR = 0.01
26
+
27
+ WEIGHT_LABELS = {
28
+ (0.8..) => :bonded,
29
+ (0.6...0.8) => :strong,
30
+ (0.4...0.6) => :moderate,
31
+ (0.2...0.4) => :weak,
32
+ (..0.2) => :nascent
33
+ }.freeze
34
+
35
+ ASSEMBLY_STATE_LABELS = {
36
+ active: 'assembly currently firing',
37
+ primed: 'assembly recently active',
38
+ dormant: 'assembly stable but inactive',
39
+ forming: 'assembly still consolidating',
40
+ dissolving: 'assembly losing coherence'
41
+ }.freeze
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module HebbianAssembly
6
+ module Helpers
7
+ class Unit
8
+ include Constants
9
+
10
+ attr_reader :id, :domain, :connections, :activation_count, :last_activated
11
+ attr_accessor :activation_level
12
+
13
+ def initialize(id:, domain: :general)
14
+ @id = id
15
+ @domain = domain
16
+ @activation_level = 0.0
17
+ @connections = {}
18
+ @activation_count = 0
19
+ @last_activated = nil
20
+ end
21
+
22
+ def activate(level: 1.0)
23
+ @activation_level = level.to_f.clamp(0.0, 1.0)
24
+ @activation_count += 1
25
+ @last_activated = Time.now.utc
26
+ end
27
+
28
+ def active?
29
+ @activation_level >= ACTIVATION_THRESHOLD
30
+ end
31
+
32
+ def connect(other_id, weight: DEFAULT_WEIGHT)
33
+ return if @connections.size >= MAX_CONNECTIONS_PER_UNIT && !@connections.key?(other_id)
34
+
35
+ @connections[other_id] = weight.to_f.clamp(WEIGHT_FLOOR, MAX_WEIGHT)
36
+ end
37
+
38
+ def strengthen(other_id, amount: LEARNING_RATE)
39
+ return unless @connections.key?(other_id)
40
+
41
+ @connections[other_id] = [@connections[other_id] + amount, MAX_WEIGHT].min
42
+ end
43
+
44
+ def weaken(other_id, amount: COMPETITION_FACTOR)
45
+ return unless @connections.key?(other_id)
46
+
47
+ @connections[other_id] = [@connections[other_id] - amount, WEIGHT_FLOOR].max
48
+ end
49
+
50
+ def weight_to(other_id)
51
+ @connections[other_id] || 0.0
52
+ end
53
+
54
+ def weight_label(other_id)
55
+ w = weight_to(other_id)
56
+ WEIGHT_LABELS.each { |range, lbl| return lbl if range.cover?(w) }
57
+ :nascent
58
+ end
59
+
60
+ def decay_weights
61
+ @connections.each do |k, w|
62
+ @connections[k] = [w - WEIGHT_DECAY, WEIGHT_FLOOR].max
63
+ end
64
+ @connections.reject! { |_, w| w <= WEIGHT_FLOOR }
65
+ end
66
+
67
+ def connection_count
68
+ @connections.size
69
+ end
70
+
71
+ def strongest_connections(n = 5)
72
+ @connections.sort_by { |_, w| -w }.first(n).to_h
73
+ end
74
+
75
+ def to_h
76
+ {
77
+ id: @id,
78
+ domain: @domain,
79
+ activation_level: @activation_level.round(4),
80
+ active: active?,
81
+ connections: @connections.size,
82
+ activation_count: @activation_count,
83
+ last_activated: @last_activated
84
+ }
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module HebbianAssembly
6
+ module Runners
7
+ module HebbianAssembly
8
+ include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers) &&
9
+ Legion::Extensions::Helpers.const_defined?(:Lex)
10
+
11
+ def activate_unit(id:, level: 1.0, domain: :general, **)
12
+ Legion::Logging.debug "[hebbian] activate: id=#{id} level=#{level}"
13
+ network.add_unit(id: id, domain: domain)
14
+ unit = network.activate_unit(id: id, level: level)
15
+ { success: true, unit: unit.to_h, assemblies: network.assembly_count }
16
+ end
17
+
18
+ def co_activate_units(ids:, level: 1.0, **)
19
+ Legion::Logging.debug "[hebbian] co_activate: ids=#{ids}"
20
+ results = network.co_activate(ids: ids, level: level)
21
+ { success: true, units: results, assemblies: network.assembly_count }
22
+ end
23
+
24
+ def query_weight(from:, to:, **)
25
+ w = network.query_weight(from: from, to: to)
26
+ label = Helpers::Constants::WEIGHT_LABELS.each { |range, l| break l if range.cover?(w) }
27
+ label = :nascent unless label.is_a?(Symbol)
28
+ Legion::Logging.debug "[hebbian] weight: #{from}->#{to} = #{w}"
29
+ { success: true, from: from, to: to, weight: w.round(4), label: label }
30
+ end
31
+
32
+ def list_assemblies(**)
33
+ assemblies = network.assemblies.values.map(&:to_h)
34
+ Legion::Logging.debug "[hebbian] list_assemblies: #{assemblies.size}"
35
+ { success: true, assemblies: assemblies, count: assemblies.size }
36
+ end
37
+
38
+ def query_assembly(id:, **)
39
+ asm = network.query_assembly(id: id.to_sym)
40
+ Legion::Logging.debug "[hebbian] query_assembly: id=#{id} found=#{!asm.nil?}"
41
+ if asm
42
+ { success: true, assembly: asm.to_h }
43
+ else
44
+ { success: false, reason: :not_found }
45
+ end
46
+ end
47
+
48
+ def pattern_complete(partial_ids:, **)
49
+ Legion::Logging.debug "[hebbian] pattern_complete: partial=#{partial_ids}"
50
+ result = network.pattern_complete(partial_ids: partial_ids)
51
+ if result
52
+ { success: true, completion: result }
53
+ else
54
+ { success: false, reason: :no_matching_assembly }
55
+ end
56
+ end
57
+
58
+ def strongest_units(limit: 10, **)
59
+ units = network.strongest_units(limit.to_i)
60
+ Legion::Logging.debug "[hebbian] strongest_units: #{units.size}"
61
+ { success: true, units: units }
62
+ end
63
+
64
+ def assemblies_for(unit_id:, **)
65
+ asms = network.assemblies_containing(unit_id: unit_id).map(&:to_h)
66
+ Legion::Logging.debug "[hebbian] assemblies_for: unit=#{unit_id} count=#{asms.size}"
67
+ { success: true, assemblies: asms, count: asms.size }
68
+ end
69
+
70
+ def update_hebbian(**)
71
+ Legion::Logging.debug '[hebbian] decay tick'
72
+ network.decay_all
73
+ { success: true, units: network.unit_count, assemblies: network.assembly_count }
74
+ end
75
+
76
+ def hebbian_stats(**)
77
+ Legion::Logging.debug '[hebbian] stats'
78
+ { success: true, stats: network.to_h }
79
+ end
80
+
81
+ private
82
+
83
+ def network
84
+ @network ||= Helpers::AssemblyNetwork.new
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module HebbianAssembly
6
+ VERSION = '0.1.0'
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/hebbian_assembly/version'
4
+ require 'legion/extensions/hebbian_assembly/helpers/constants'
5
+ require 'legion/extensions/hebbian_assembly/helpers/unit'
6
+ require 'legion/extensions/hebbian_assembly/helpers/assembly'
7
+ require 'legion/extensions/hebbian_assembly/helpers/assembly_network'
8
+ require 'legion/extensions/hebbian_assembly/runners/hebbian_assembly'
9
+ require 'legion/extensions/hebbian_assembly/client'
10
+
11
+ module Legion
12
+ module Extensions
13
+ module HebbianAssembly
14
+ extend Legion::Extensions::Core if Legion::Extensions.const_defined? :Core
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::HebbianAssembly::Client do
4
+ subject(:client) { described_class.new }
5
+
6
+ it 'includes Runners::HebbianAssembly' do
7
+ expect(described_class.ancestors).to include(Legion::Extensions::HebbianAssembly::Runners::HebbianAssembly)
8
+ end
9
+
10
+ it 'supports full Hebbian learning lifecycle' do
11
+ # Repeatedly co-activate a pattern
12
+ 10.times { client.co_activate_units(ids: %i[see dog bark]) }
13
+
14
+ # Assembly should form
15
+ assemblies = client.list_assemblies
16
+ expect(assemblies[:count]).to be >= 1
17
+
18
+ # Pattern completion: given "see" and "dog", predict "bark"
19
+ completion = client.pattern_complete(partial_ids: %i[see dog])
20
+ expect(completion[:success]).to be true
21
+ expect(completion[:completion][:predicted]).to include(:bark)
22
+
23
+ # Weights should be strong
24
+ weight = client.query_weight(from: :see, to: :dog)
25
+ expect(weight[:weight]).to be > 0.3
26
+
27
+ # Find assemblies containing :dog
28
+ dog_asms = client.assemblies_for(unit_id: :dog)
29
+ expect(dog_asms[:count]).to be >= 1
30
+
31
+ # Decay
32
+ client.update_hebbian
33
+
34
+ # Stats
35
+ stats = client.hebbian_stats
36
+ expect(stats[:stats][:units]).to eq(3)
37
+ end
38
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::HebbianAssembly::Helpers::AssemblyNetwork do
4
+ subject(:network) { described_class.new }
5
+
6
+ let(:constants) { Legion::Extensions::HebbianAssembly::Helpers::Constants }
7
+
8
+ describe '#add_unit' do
9
+ it 'adds a unit' do
10
+ unit = network.add_unit(id: :a)
11
+ expect(unit.id).to eq(:a)
12
+ expect(network.unit_count).to eq(1)
13
+ end
14
+
15
+ it 'returns existing unit on duplicate' do
16
+ first = network.add_unit(id: :a)
17
+ second = network.add_unit(id: :a)
18
+ expect(first).to equal(second)
19
+ end
20
+
21
+ it 'limits units' do
22
+ constants::MAX_UNITS.times { |i| network.add_unit(id: :"u_#{i}") }
23
+ expect(network.add_unit(id: :overflow)).to be_nil
24
+ end
25
+ end
26
+
27
+ describe '#activate_unit' do
28
+ it 'activates a unit' do
29
+ unit = network.activate_unit(id: :a, level: 0.8)
30
+ expect(unit.activation_level).to eq(0.8)
31
+ expect(unit.active?).to be true
32
+ end
33
+
34
+ it 'auto-creates unit if missing' do
35
+ network.activate_unit(id: :new_unit)
36
+ expect(network.unit_count).to eq(1)
37
+ end
38
+
39
+ it 'records activation history' do
40
+ network.activate_unit(id: :a)
41
+ expect(network.activation_history.size).to eq(1)
42
+ end
43
+ end
44
+
45
+ describe '#co_activate' do
46
+ it 'activates multiple units' do
47
+ results = network.co_activate(ids: %i[a b c])
48
+ expect(results.size).to eq(3)
49
+ expect(network.unit_count).to eq(3)
50
+ end
51
+
52
+ it 'strengthens connections between co-activated units' do
53
+ network.co_activate(ids: %i[a b])
54
+ expect(network.query_weight(from: :a, to: :b)).to be > 0
55
+ expect(network.query_weight(from: :b, to: :a)).to be > 0
56
+ end
57
+
58
+ it 'repeated co-activation increases weight' do
59
+ network.co_activate(ids: %i[a b])
60
+ first_weight = network.query_weight(from: :a, to: :b)
61
+ network.co_activate(ids: %i[a b])
62
+ expect(network.query_weight(from: :a, to: :b)).to be > first_weight
63
+ end
64
+ end
65
+
66
+ describe '#query_weight' do
67
+ it 'returns 0 for unconnected units' do
68
+ expect(network.query_weight(from: :a, to: :b)).to eq(0.0)
69
+ end
70
+ end
71
+
72
+ describe 'assembly detection' do
73
+ it 'forms assembly from repeated co-activation of 3+ units' do
74
+ 10.times { network.co_activate(ids: %i[a b c], level: 1.0) }
75
+ expect(network.assembly_count).to be >= 1
76
+ end
77
+
78
+ it 'consolidates existing assembly on repeated co-activation' do
79
+ 10.times { network.co_activate(ids: %i[a b c], level: 1.0) }
80
+ assembly = network.assemblies.values.first
81
+ first_coherence = assembly.coherence
82
+ 5.times { network.co_activate(ids: %i[a b c], level: 1.0) }
83
+ expect(assembly.coherence).to be >= first_coherence
84
+ end
85
+ end
86
+
87
+ describe '#assemblies_containing' do
88
+ it 'finds assemblies containing a unit' do
89
+ 10.times { network.co_activate(ids: %i[a b c], level: 1.0) }
90
+ asms = network.assemblies_containing(unit_id: :a)
91
+ expect(asms).not_to be_empty
92
+ expect(asms.first.includes?(:a)).to be true
93
+ end
94
+ end
95
+
96
+ describe '#pattern_complete' do
97
+ it 'completes partial pattern from assembly' do
98
+ 10.times { network.co_activate(ids: %i[a b c], level: 1.0) }
99
+ result = network.pattern_complete(partial_ids: %i[a b])
100
+ expect(result).not_to be_nil
101
+ expect(result[:predicted]).to include(:c)
102
+ end
103
+
104
+ it 'returns nil when no matching assembly' do
105
+ expect(network.pattern_complete(partial_ids: [:unknown])).to be_nil
106
+ end
107
+ end
108
+
109
+ describe '#strongest_units' do
110
+ it 'returns most activated units' do
111
+ 5.times { network.activate_unit(id: :frequent, level: 1.0) }
112
+ network.activate_unit(id: :rare, level: 1.0)
113
+ top = network.strongest_units(1)
114
+ expect(top.first[:id]).to eq(:frequent)
115
+ end
116
+ end
117
+
118
+ describe '#decay_all' do
119
+ it 'decays unit weights' do
120
+ network.co_activate(ids: %i[a b])
121
+ before = network.query_weight(from: :a, to: :b)
122
+ network.decay_all
123
+ expect(network.query_weight(from: :a, to: :b)).to be < before
124
+ end
125
+
126
+ it 'removes dissolving assemblies' do
127
+ 10.times { network.co_activate(ids: %i[a b c], level: 1.0) }
128
+ network.assemblies.each_value { |a| a.coherence = 0.1 }
129
+ network.decay_all
130
+ expect(network.assembly_count).to eq(0)
131
+ end
132
+ end
133
+
134
+ describe '#to_h' do
135
+ it 'returns stats' do
136
+ network.co_activate(ids: %i[a b c])
137
+ h = network.to_h
138
+ expect(h).to include(:units, :assemblies, :total_connections, :history_size, :active_units)
139
+ expect(h[:units]).to eq(3)
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::HebbianAssembly::Helpers::Assembly do
4
+ subject(:assembly) { described_class.new(id: :asm_one, member_ids: %i[a b c]) }
5
+
6
+ let(:constants) { Legion::Extensions::HebbianAssembly::Helpers::Constants }
7
+
8
+ describe '#initialize' do
9
+ it 'sets attributes' do
10
+ expect(assembly.id).to eq(:asm_one)
11
+ expect(assembly.member_ids).to eq(%i[a b c])
12
+ expect(assembly.coherence).to eq(0.5)
13
+ end
14
+
15
+ it 'deduplicates members' do
16
+ asm = described_class.new(id: :dup, member_ids: %i[a a b])
17
+ expect(asm.member_ids).to eq(%i[a b])
18
+ end
19
+ end
20
+
21
+ describe '#activate' do
22
+ it 'increments activation count' do
23
+ assembly.activate
24
+ expect(assembly.activation_count).to eq(1)
25
+ expect(assembly.last_activated).to be_a(Time)
26
+ end
27
+ end
28
+
29
+ describe '#consolidate' do
30
+ it 'increases coherence' do
31
+ before = assembly.coherence
32
+ assembly.consolidate
33
+ expect(assembly.coherence).to be > before
34
+ end
35
+ end
36
+
37
+ describe '#decay' do
38
+ it 'decreases coherence' do
39
+ before = assembly.coherence
40
+ assembly.decay
41
+ expect(assembly.coherence).to be < before
42
+ end
43
+ end
44
+
45
+ describe '#dissolving?' do
46
+ it 'returns false when coherent' do
47
+ expect(assembly.dissolving?).to be false
48
+ end
49
+
50
+ it 'returns true when coherence drops below threshold' do
51
+ assembly.coherence = 0.1
52
+ expect(assembly.dissolving?).to be true
53
+ end
54
+ end
55
+
56
+ describe '#includes?' do
57
+ it 'returns true for members' do
58
+ expect(assembly.includes?(:a)).to be true
59
+ end
60
+
61
+ it 'returns false for non-members' do
62
+ expect(assembly.includes?(:z)).to be false
63
+ end
64
+ end
65
+
66
+ describe '#state' do
67
+ it 'returns :dormant for stable inactive assembly' do
68
+ expect(assembly.state).to eq(:dormant)
69
+ end
70
+
71
+ it 'returns :active when recently activated' do
72
+ assembly.activate
73
+ expect(assembly.state).to eq(:active)
74
+ end
75
+
76
+ it 'returns :dissolving when coherence is low' do
77
+ assembly.coherence = 0.1
78
+ expect(assembly.state).to eq(:dissolving)
79
+ end
80
+ end
81
+
82
+ describe '#to_h' do
83
+ it 'returns hash with all fields' do
84
+ h = assembly.to_h
85
+ expect(h).to include(:id, :members, :member_count, :coherence, :state, :state_label, :activation_count)
86
+ expect(h[:member_count]).to eq(3)
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::HebbianAssembly::Helpers::Unit do
4
+ subject(:unit) { described_class.new(id: :neuron_a, domain: :cognition) }
5
+
6
+ let(:constants) { Legion::Extensions::HebbianAssembly::Helpers::Constants }
7
+
8
+ describe '#initialize' do
9
+ it 'sets attributes' do
10
+ expect(unit.id).to eq(:neuron_a)
11
+ expect(unit.domain).to eq(:cognition)
12
+ expect(unit.activation_level).to eq(0.0)
13
+ expect(unit.activation_count).to eq(0)
14
+ end
15
+ end
16
+
17
+ describe '#activate' do
18
+ it 'sets activation level and increments count' do
19
+ unit.activate(level: 0.8)
20
+ expect(unit.activation_level).to eq(0.8)
21
+ expect(unit.activation_count).to eq(1)
22
+ expect(unit.last_activated).to be_a(Time)
23
+ end
24
+
25
+ it 'clamps activation' do
26
+ unit.activate(level: 1.5)
27
+ expect(unit.activation_level).to eq(1.0)
28
+ end
29
+ end
30
+
31
+ describe '#active?' do
32
+ it 'returns true when above threshold' do
33
+ unit.activate(level: 0.5)
34
+ expect(unit.active?).to be true
35
+ end
36
+
37
+ it 'returns false when below threshold' do
38
+ expect(unit.active?).to be false
39
+ end
40
+ end
41
+
42
+ describe '#connect' do
43
+ it 'creates a connection' do
44
+ unit.connect(:neuron_b, weight: 0.3)
45
+ expect(unit.weight_to(:neuron_b)).to eq(0.3)
46
+ end
47
+
48
+ it 'clamps weight' do
49
+ unit.connect(:neuron_b, weight: 2.0)
50
+ expect(unit.weight_to(:neuron_b)).to eq(1.0)
51
+ end
52
+
53
+ it 'limits connections' do
54
+ constants::MAX_CONNECTIONS_PER_UNIT.times { |i| unit.connect(:"n_#{i}") }
55
+ unit.connect(:overflow)
56
+ expect(unit.connection_count).to eq(constants::MAX_CONNECTIONS_PER_UNIT)
57
+ end
58
+ end
59
+
60
+ describe '#strengthen' do
61
+ it 'increases weight' do
62
+ unit.connect(:neuron_b, weight: 0.3)
63
+ unit.strengthen(:neuron_b)
64
+ expect(unit.weight_to(:neuron_b)).to eq(0.3 + constants::LEARNING_RATE)
65
+ end
66
+
67
+ it 'caps at MAX_WEIGHT' do
68
+ unit.connect(:neuron_b, weight: 0.98)
69
+ unit.strengthen(:neuron_b)
70
+ expect(unit.weight_to(:neuron_b)).to eq(constants::MAX_WEIGHT)
71
+ end
72
+ end
73
+
74
+ describe '#weaken' do
75
+ it 'decreases weight' do
76
+ unit.connect(:neuron_b, weight: 0.5)
77
+ unit.weaken(:neuron_b)
78
+ expect(unit.weight_to(:neuron_b)).to eq(0.5 - constants::COMPETITION_FACTOR)
79
+ end
80
+ end
81
+
82
+ describe '#decay_weights' do
83
+ it 'decays all connections' do
84
+ unit.connect(:neuron_b, weight: 0.5)
85
+ unit.decay_weights
86
+ expect(unit.weight_to(:neuron_b)).to eq(0.5 - constants::WEIGHT_DECAY)
87
+ end
88
+
89
+ it 'prunes connections at floor' do
90
+ unit.connect(:neuron_b, weight: constants::WEIGHT_FLOOR + 0.001)
91
+ unit.decay_weights
92
+ expect(unit.connection_count).to eq(0)
93
+ end
94
+ end
95
+
96
+ describe '#weight_label' do
97
+ it 'returns label for weight' do
98
+ unit.connect(:neuron_b, weight: 0.9)
99
+ expect(unit.weight_label(:neuron_b)).to eq(:bonded)
100
+ end
101
+ end
102
+
103
+ describe '#strongest_connections' do
104
+ it 'returns top connections sorted by weight' do
105
+ unit.connect(:a, weight: 0.3)
106
+ unit.connect(:b, weight: 0.8)
107
+ unit.connect(:c, weight: 0.5)
108
+ top = unit.strongest_connections(2)
109
+ expect(top.keys).to eq(%i[b c])
110
+ end
111
+ end
112
+
113
+ describe '#to_h' do
114
+ it 'returns hash' do
115
+ h = unit.to_h
116
+ expect(h).to include(:id, :domain, :activation_level, :active, :connections, :activation_count)
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::HebbianAssembly::Runners::HebbianAssembly do
4
+ let(:client) { Legion::Extensions::HebbianAssembly::Client.new }
5
+
6
+ describe '#activate_unit' do
7
+ it 'activates a unit' do
8
+ result = client.activate_unit(id: :a, level: 0.8, domain: :cognition)
9
+ expect(result[:success]).to be true
10
+ expect(result[:unit][:id]).to eq(:a)
11
+ expect(result[:unit][:active]).to be true
12
+ end
13
+ end
14
+
15
+ describe '#co_activate_units' do
16
+ it 'co-activates multiple units' do
17
+ result = client.co_activate_units(ids: %i[a b c])
18
+ expect(result[:success]).to be true
19
+ expect(result[:units].size).to eq(3)
20
+ end
21
+ end
22
+
23
+ describe '#query_weight' do
24
+ it 'returns weight between units' do
25
+ client.co_activate_units(ids: %i[a b])
26
+ result = client.query_weight(from: :a, to: :b)
27
+ expect(result[:success]).to be true
28
+ expect(result[:weight]).to be > 0
29
+ expect(result[:label]).to be_a(Symbol)
30
+ end
31
+
32
+ it 'returns 0 for unconnected' do
33
+ result = client.query_weight(from: :x, to: :y)
34
+ expect(result[:weight]).to eq(0.0)
35
+ end
36
+ end
37
+
38
+ describe '#list_assemblies' do
39
+ it 'lists assemblies' do
40
+ 10.times { client.co_activate_units(ids: %i[a b c]) }
41
+ result = client.list_assemblies
42
+ expect(result[:success]).to be true
43
+ expect(result[:count]).to be >= 1
44
+ end
45
+ end
46
+
47
+ describe '#query_assembly' do
48
+ it 'queries a specific assembly' do
49
+ 10.times { client.co_activate_units(ids: %i[a b c]) }
50
+ all = client.list_assemblies
51
+ id = all[:assemblies].first[:id]
52
+ result = client.query_assembly(id: id)
53
+ expect(result[:success]).to be true
54
+ expect(result[:assembly][:members]).to include(:a, :b, :c)
55
+ end
56
+
57
+ it 'returns not_found for unknown' do
58
+ result = client.query_assembly(id: :nonexistent)
59
+ expect(result[:success]).to be false
60
+ end
61
+ end
62
+
63
+ describe '#pattern_complete' do
64
+ it 'completes partial pattern' do
65
+ 10.times { client.co_activate_units(ids: %i[a b c]) }
66
+ result = client.pattern_complete(partial_ids: %i[a b])
67
+ expect(result[:success]).to be true
68
+ expect(result[:completion][:predicted]).to include(:c)
69
+ end
70
+
71
+ it 'returns failure when no match' do
72
+ result = client.pattern_complete(partial_ids: [:unknown])
73
+ expect(result[:success]).to be false
74
+ end
75
+ end
76
+
77
+ describe '#strongest_units' do
78
+ it 'returns most activated units' do
79
+ 5.times { client.activate_unit(id: :freq) }
80
+ client.activate_unit(id: :rare)
81
+ result = client.strongest_units(limit: 1)
82
+ expect(result[:units].first[:id]).to eq(:freq)
83
+ end
84
+ end
85
+
86
+ describe '#assemblies_for' do
87
+ it 'finds assemblies for a unit' do
88
+ 10.times { client.co_activate_units(ids: %i[a b c]) }
89
+ result = client.assemblies_for(unit_id: :a)
90
+ expect(result[:success]).to be true
91
+ expect(result[:count]).to be >= 1
92
+ end
93
+ end
94
+
95
+ describe '#update_hebbian' do
96
+ it 'runs decay tick' do
97
+ result = client.update_hebbian
98
+ expect(result[:success]).to be true
99
+ end
100
+ end
101
+
102
+ describe '#hebbian_stats' do
103
+ it 'returns stats' do
104
+ result = client.hebbian_stats
105
+ expect(result[:success]).to be true
106
+ expect(result[:stats]).to include(:units, :assemblies, :total_connections)
107
+ end
108
+ end
109
+ 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/hebbian_assembly'
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,80 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: lex-hebbian-assembly
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: Hebb's Cell Assembly Theory for brain-modeled agentic AI — neurons that
27
+ fire together wire together, forming emergent assemblies that represent concepts,
28
+ enable pattern completion, and support associative recall through co-activation-driven
29
+ synaptic strengthening.
30
+ email:
31
+ - matthewdiverson@gmail.com
32
+ executables: []
33
+ extensions: []
34
+ extra_rdoc_files: []
35
+ files:
36
+ - Gemfile
37
+ - lex-hebbian-assembly.gemspec
38
+ - lib/legion/extensions/hebbian_assembly.rb
39
+ - lib/legion/extensions/hebbian_assembly/actors/decay.rb
40
+ - lib/legion/extensions/hebbian_assembly/client.rb
41
+ - lib/legion/extensions/hebbian_assembly/helpers/assembly.rb
42
+ - lib/legion/extensions/hebbian_assembly/helpers/assembly_network.rb
43
+ - lib/legion/extensions/hebbian_assembly/helpers/constants.rb
44
+ - lib/legion/extensions/hebbian_assembly/helpers/unit.rb
45
+ - lib/legion/extensions/hebbian_assembly/runners/hebbian_assembly.rb
46
+ - lib/legion/extensions/hebbian_assembly/version.rb
47
+ - spec/legion/extensions/hebbian_assembly/client_spec.rb
48
+ - spec/legion/extensions/hebbian_assembly/helpers/assembly_network_spec.rb
49
+ - spec/legion/extensions/hebbian_assembly/helpers/assembly_spec.rb
50
+ - spec/legion/extensions/hebbian_assembly/helpers/unit_spec.rb
51
+ - spec/legion/extensions/hebbian_assembly/runners/hebbian_assembly_spec.rb
52
+ - spec/spec_helper.rb
53
+ homepage: https://github.com/LegionIO/lex-hebbian-assembly
54
+ licenses:
55
+ - MIT
56
+ metadata:
57
+ homepage_uri: https://github.com/LegionIO/lex-hebbian-assembly
58
+ source_code_uri: https://github.com/LegionIO/lex-hebbian-assembly
59
+ documentation_uri: https://github.com/LegionIO/lex-hebbian-assembly
60
+ changelog_uri: https://github.com/LegionIO/lex-hebbian-assembly
61
+ bug_tracker_uri: https://github.com/LegionIO/lex-hebbian-assembly/issues
62
+ rubygems_mfa_required: 'true'
63
+ rdoc_options: []
64
+ require_paths:
65
+ - lib
66
+ required_ruby_version: !ruby/object:Gem::Requirement
67
+ requirements:
68
+ - - ">="
69
+ - !ruby/object:Gem::Version
70
+ version: '3.4'
71
+ required_rubygems_version: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ requirements: []
77
+ rubygems_version: 3.6.9
78
+ specification_version: 4
79
+ summary: LEX Hebbian Assembly
80
+ test_files: []