lex-global-workspace 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: 7e43f7f4fe9651e9509cdc5b2c92212b617571f81996118c258bf91f4226c2a7
4
+ data.tar.gz: a9a08a51d92c11f4d1480e50b6768202f498c7ec179d9e589104d7aa3bb9ffa5
5
+ SHA512:
6
+ metadata.gz: aa1b26d8aed0354075e509ea803f819b344d60c70686399c9277e12083b1b059671d36b4d11511710633d2d8bd6111ae726605c96109141c7bb3fc0fc4a77b43
7
+ data.tar.gz: 363e6dc2064a884834078c146f1310ef266714dc61295e12d3bca341a6c2f730dd5cb8ccae5cdbe6e2be86e401c4b454b22f4b30ce96534031c137fafe881099
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/global_workspace/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'lex-global-workspace'
7
+ spec.version = Legion::Extensions::GlobalWorkspace::VERSION
8
+ spec.authors = ['Esity']
9
+ spec.email = ['matthewdiverson@gmail.com']
10
+
11
+ spec.summary = 'LEX Global Workspace'
12
+ spec.description = "Baars' Global Workspace Theory for brain-modeled agentic AI — information " \
13
+ 'competes for access to a limited-capacity workspace; winners are broadcast to ' \
14
+ 'all subscribed cognitive subsystems, implementing a computational model of ' \
15
+ 'conscious access and attentional bottleneck.'
16
+ spec.homepage = 'https://github.com/LegionIO/lex-global-workspace'
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-global-workspace'
22
+ spec.metadata['documentation_uri'] = 'https://github.com/LegionIO/lex-global-workspace'
23
+ spec.metadata['changelog_uri'] = 'https://github.com/LegionIO/lex-global-workspace'
24
+ spec.metadata['bug_tracker_uri'] = 'https://github.com/LegionIO/lex-global-workspace/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-global-workspace.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 GlobalWorkspace
8
+ module Actor
9
+ class Competition < Legion::Extensions::Actors::Every
10
+ def runner_class
11
+ Legion::Extensions::GlobalWorkspace::Runners::GlobalWorkspace
12
+ end
13
+
14
+ def runner_function
15
+ 'update_global_workspace'
16
+ end
17
+
18
+ def time
19
+ 2
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/global_workspace/helpers/constants'
4
+ require 'legion/extensions/global_workspace/helpers/broadcast'
5
+ require 'legion/extensions/global_workspace/helpers/competitor'
6
+ require 'legion/extensions/global_workspace/helpers/workspace'
7
+ require 'legion/extensions/global_workspace/runners/global_workspace'
8
+
9
+ module Legion
10
+ module Extensions
11
+ module GlobalWorkspace
12
+ class Client
13
+ include Runners::GlobalWorkspace
14
+
15
+ def initialize(workspace: nil, **)
16
+ @workspace = workspace || Helpers::Workspace.new
17
+ end
18
+
19
+ private
20
+
21
+ attr_reader :workspace
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module GlobalWorkspace
6
+ module Helpers
7
+ class Broadcast
8
+ include Constants
9
+
10
+ attr_reader :content, :source, :domain, :salience, :coalition,
11
+ :broadcast_at, :received_by
12
+
13
+ def initialize(content:, source:, domain:, salience:, coalition: [])
14
+ @content = content
15
+ @source = source
16
+ @domain = domain
17
+ @salience = salience.to_f.clamp(0.0, 1.0)
18
+ @coalition = Array(coalition).first(MAX_COALITION_SIZE)
19
+ @broadcast_at = Time.now.utc
20
+ @received_by = []
21
+ end
22
+
23
+ def acknowledge(subscriber_id)
24
+ @received_by << subscriber_id unless @received_by.include?(subscriber_id)
25
+ end
26
+
27
+ def expired?
28
+ (Time.now.utc - @broadcast_at) > BROADCAST_TTL
29
+ end
30
+
31
+ def age
32
+ Time.now.utc - @broadcast_at
33
+ end
34
+
35
+ def label
36
+ SALIENCE_LABELS.each { |range, lbl| return lbl if range.cover?(@salience) }
37
+ :subliminal
38
+ end
39
+
40
+ def to_h
41
+ {
42
+ content: @content,
43
+ source: @source,
44
+ domain: @domain,
45
+ salience: @salience.round(4),
46
+ label: label,
47
+ coalition: @coalition,
48
+ broadcast_at: @broadcast_at,
49
+ age: age.round(2),
50
+ received_by: @received_by.dup,
51
+ expired: expired?
52
+ }
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module GlobalWorkspace
6
+ module Helpers
7
+ class Competitor
8
+ include Constants
9
+
10
+ attr_reader :content, :source, :domain, :coalition, :submitted_at
11
+ attr_accessor :salience, :urgency
12
+
13
+ def initialize(content:, source:, domain:, salience:, coalition: [])
14
+ @content = content
15
+ @source = source
16
+ @domain = domain
17
+ @salience = salience.to_f.clamp(0.0, 1.0)
18
+ @coalition = Array(coalition).first(MAX_COALITION_SIZE)
19
+ @urgency = 0.0
20
+ @submitted_at = Time.now.utc
21
+ end
22
+
23
+ def effective_salience
24
+ (@salience + @urgency).clamp(0.0, 1.0)
25
+ end
26
+
27
+ def decay
28
+ @salience = [@salience - SALIENCE_DECAY, 0.0].max
29
+ end
30
+
31
+ def boost_urgency
32
+ @urgency = [@urgency + URGENCY_BOOST, MAX_URGENCY].min
33
+ end
34
+
35
+ def below_threshold?
36
+ effective_salience < COMPETITION_THRESHOLD
37
+ end
38
+
39
+ def to_h
40
+ {
41
+ content: @content,
42
+ source: @source,
43
+ domain: @domain,
44
+ salience: @salience.round(4),
45
+ urgency: @urgency.round(4),
46
+ effective_salience: effective_salience.round(4),
47
+ coalition: @coalition,
48
+ submitted_at: @submitted_at
49
+ }
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module GlobalWorkspace
6
+ module Helpers
7
+ module Constants
8
+ # Maximum items that can compete for the workspace in one cycle
9
+ MAX_COMPETITORS = 50
10
+
11
+ # Maximum broadcasts retained in history
12
+ MAX_BROADCAST_HISTORY = 200
13
+
14
+ # Maximum number of coalition members per broadcast
15
+ MAX_COALITION_SIZE = 10
16
+
17
+ # Minimum salience to enter competition
18
+ COMPETITION_THRESHOLD = 0.2
19
+
20
+ # Winner takes all — top competitor must exceed runner-up by this margin
21
+ DOMINANCE_MARGIN = 0.05
22
+
23
+ # How long a broadcast remains "conscious" (seconds)
24
+ BROADCAST_TTL = 10
25
+
26
+ # Salience decay per tick for waiting competitors
27
+ SALIENCE_DECAY = 0.02
28
+
29
+ # Urgency boost per tick for items that keep losing competition
30
+ URGENCY_BOOST = 0.01
31
+
32
+ # Maximum urgency accumulation
33
+ MAX_URGENCY = 0.5
34
+
35
+ # EMA alpha for workspace utilization tracking
36
+ UTILIZATION_ALPHA = 0.1
37
+
38
+ # Maximum registered subscribers
39
+ MAX_SUBSCRIBERS = 50
40
+
41
+ # Labels for workspace state
42
+ WORKSPACE_STATE_LABELS = {
43
+ broadcasting: 'actively broadcasting content',
44
+ idle: 'workspace empty, awaiting input',
45
+ contention: 'multiple items competing for access',
46
+ saturated: 'high utilization, processing backlog'
47
+ }.freeze
48
+
49
+ # Labels for broadcast salience
50
+ SALIENCE_LABELS = {
51
+ (0.8..) => :dominant,
52
+ (0.6...0.8) => :salient,
53
+ (0.4...0.6) => :moderate,
54
+ (0.2...0.4) => :marginal,
55
+ (..0.2) => :subliminal
56
+ }.freeze
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module GlobalWorkspace
6
+ module Helpers
7
+ class Workspace
8
+ include Constants
9
+
10
+ attr_reader :competitors, :broadcast_history, :subscribers, :current_broadcast, :utilization
11
+
12
+ def initialize
13
+ @competitors = []
14
+ @broadcast_history = []
15
+ @subscribers = {} # id => { name:, domains: [] }
16
+ @current_broadcast = nil
17
+ @utilization = 0.0 # EMA of workspace usage
18
+ @competition_count = 0
19
+ end
20
+
21
+ # --- Subscriber Management ---
22
+
23
+ def register_subscriber(id:, name:, domains: [])
24
+ return false if @subscribers.size >= MAX_SUBSCRIBERS && !@subscribers.key?(id)
25
+
26
+ @subscribers[id] = { name: name, domains: Array(domains), registered_at: Time.now.utc }
27
+ true
28
+ end
29
+
30
+ def unregister_subscriber(id:)
31
+ !@subscribers.delete(id).nil?
32
+ end
33
+
34
+ # --- Competition ---
35
+
36
+ def submit(content:, source:, domain:, salience:, coalition: [])
37
+ return nil if salience.to_f < COMPETITION_THRESHOLD
38
+
39
+ @competitors.shift while @competitors.size >= MAX_COMPETITORS
40
+ competitor = Competitor.new(
41
+ content: content,
42
+ source: source,
43
+ domain: domain,
44
+ salience: salience,
45
+ coalition: coalition
46
+ )
47
+ @competitors << competitor
48
+ competitor
49
+ end
50
+
51
+ def compete
52
+ @competition_count += 1
53
+ expire_current_broadcast
54
+ prune_weak_competitors
55
+
56
+ return nil if @competitors.empty?
57
+
58
+ sorted = @competitors.sort_by { |c| -c.effective_salience }
59
+ winner = sorted.first
60
+
61
+ if sorted.size > 1
62
+ runner_up = sorted[1]
63
+ unless (winner.effective_salience - runner_up.effective_salience) >= DOMINANCE_MARGIN
64
+ boost_losers
65
+ update_utilization(busy: true)
66
+ return nil
67
+ end
68
+ end
69
+
70
+ @competitors.delete(winner)
71
+ broadcast = create_broadcast(winner)
72
+ @current_broadcast = broadcast
73
+
74
+ boost_losers
75
+ update_utilization(busy: true)
76
+
77
+ broadcast
78
+ end
79
+
80
+ # --- Query ---
81
+
82
+ def conscious?(content)
83
+ return false unless @current_broadcast && !@current_broadcast.expired?
84
+
85
+ @current_broadcast.content == content
86
+ end
87
+
88
+ def current_content
89
+ return nil unless @current_broadcast && !@current_broadcast.expired?
90
+
91
+ @current_broadcast.to_h
92
+ end
93
+
94
+ def acknowledge(subscriber_id:)
95
+ return false unless @current_broadcast && !@current_broadcast.expired?
96
+
97
+ @current_broadcast.acknowledge(subscriber_id)
98
+ true
99
+ end
100
+
101
+ # --- Workspace State ---
102
+
103
+ def state
104
+ if @current_broadcast && !@current_broadcast.expired?
105
+ :broadcasting
106
+ elsif @competitors.size > 1
107
+ :contention
108
+ elsif @utilization > 0.7
109
+ :saturated
110
+ else
111
+ :idle
112
+ end
113
+ end
114
+
115
+ def subscriber_count
116
+ @subscribers.size
117
+ end
118
+
119
+ def competitor_count
120
+ @competitors.size
121
+ end
122
+
123
+ # --- Tick / Maintenance ---
124
+
125
+ def tick
126
+ expire_current_broadcast
127
+ decay_competitors
128
+ prune_weak_competitors
129
+ update_utilization(busy: !@competitors.empty? || (@current_broadcast && !@current_broadcast.expired?))
130
+ end
131
+
132
+ def to_h
133
+ {
134
+ state: state,
135
+ state_label: WORKSPACE_STATE_LABELS[state],
136
+ subscribers: @subscribers.size,
137
+ competitors: @competitors.size,
138
+ broadcast_history: @broadcast_history.size,
139
+ utilization: @utilization.round(4),
140
+ competitions: @competition_count,
141
+ current_broadcast: @current_broadcast&.expired? == false ? @current_broadcast.to_h : nil
142
+ }
143
+ end
144
+
145
+ private
146
+
147
+ def create_broadcast(winner)
148
+ broadcast = Broadcast.new(
149
+ content: winner.content,
150
+ source: winner.source,
151
+ domain: winner.domain,
152
+ salience: winner.effective_salience,
153
+ coalition: winner.coalition
154
+ )
155
+ @broadcast_history << broadcast
156
+ @broadcast_history.shift while @broadcast_history.size > MAX_BROADCAST_HISTORY
157
+ broadcast
158
+ end
159
+
160
+ def expire_current_broadcast
161
+ @current_broadcast = nil if @current_broadcast&.expired?
162
+ end
163
+
164
+ def prune_weak_competitors
165
+ @competitors.reject!(&:below_threshold?)
166
+ end
167
+
168
+ def decay_competitors
169
+ @competitors.each(&:decay)
170
+ end
171
+
172
+ def boost_losers
173
+ @competitors.each(&:boost_urgency)
174
+ end
175
+
176
+ def update_utilization(busy:)
177
+ sample = busy ? 1.0 : 0.0
178
+ @utilization += (UTILIZATION_ALPHA * (sample - @utilization))
179
+ end
180
+ end
181
+ end
182
+ end
183
+ end
184
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module GlobalWorkspace
6
+ module Runners
7
+ module GlobalWorkspace
8
+ include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers) &&
9
+ Legion::Extensions::Helpers.const_defined?(:Lex)
10
+
11
+ def submit_for_broadcast(content:, source:, domain:, salience:, coalition: [], **)
12
+ Legion::Logging.debug "[global_workspace] submit: source=#{source} domain=#{domain} salience=#{salience}"
13
+ competitor = workspace.submit(
14
+ content: content,
15
+ source: source,
16
+ domain: domain,
17
+ salience: salience,
18
+ coalition: coalition
19
+ )
20
+ if competitor
21
+ { success: true, effective_salience: competitor.effective_salience.round(4),
22
+ competitors: workspace.competitor_count }
23
+ else
24
+ { success: false, reason: :below_threshold, competitors: workspace.competitor_count }
25
+ end
26
+ end
27
+
28
+ def run_competition(**)
29
+ Legion::Logging.debug '[global_workspace] run_competition'
30
+ broadcast = workspace.compete
31
+ if broadcast
32
+ {
33
+ success: true,
34
+ broadcast: broadcast.to_h,
35
+ state: workspace.state,
36
+ remaining: workspace.competitor_count
37
+ }
38
+ else
39
+ { success: false, reason: :no_winner, state: workspace.state, competitors: workspace.competitor_count }
40
+ end
41
+ end
42
+
43
+ def register_subscriber(id:, name:, domains: [], **)
44
+ Legion::Logging.debug "[global_workspace] register_subscriber: id=#{id} name=#{name}"
45
+ registered = workspace.register_subscriber(id: id, name: name, domains: domains)
46
+ { success: registered, subscriber_count: workspace.subscriber_count }
47
+ end
48
+
49
+ def unregister_subscriber(id:, **)
50
+ Legion::Logging.debug "[global_workspace] unregister_subscriber: id=#{id}"
51
+ removed = workspace.unregister_subscriber(id: id)
52
+ { success: removed, subscriber_count: workspace.subscriber_count }
53
+ end
54
+
55
+ def acknowledge_broadcast(subscriber_id:, **)
56
+ Legion::Logging.debug "[global_workspace] acknowledge: subscriber=#{subscriber_id}"
57
+ ack = workspace.acknowledge(subscriber_id: subscriber_id)
58
+ { success: ack }
59
+ end
60
+
61
+ def query_consciousness(content:, **)
62
+ is_conscious = workspace.conscious?(content)
63
+ Legion::Logging.debug "[global_workspace] conscious?(#{content}): #{is_conscious}"
64
+ { success: true, conscious: is_conscious }
65
+ end
66
+
67
+ def current_broadcast(**)
68
+ content = workspace.current_content
69
+ Legion::Logging.debug "[global_workspace] current_broadcast: #{content ? 'active' : 'none'}"
70
+ { success: true, broadcast: content }
71
+ end
72
+
73
+ def broadcast_history(limit: 10, **)
74
+ history = workspace.broadcast_history.last(limit.to_i).map(&:to_h)
75
+ Legion::Logging.debug "[global_workspace] history: #{history.size} entries"
76
+ { success: true, history: history, total: workspace.broadcast_history.size }
77
+ end
78
+
79
+ def update_global_workspace(**)
80
+ Legion::Logging.debug '[global_workspace] tick'
81
+ workspace.tick
82
+ { success: true, state: workspace.state, competitors: workspace.competitor_count,
83
+ utilization: workspace.utilization.round(4) }
84
+ end
85
+
86
+ def workspace_stats(**)
87
+ Legion::Logging.debug '[global_workspace] stats'
88
+ { success: true, stats: workspace.to_h }
89
+ end
90
+
91
+ private
92
+
93
+ def workspace
94
+ @workspace ||= Helpers::Workspace.new
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module GlobalWorkspace
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/global_workspace/version'
4
+ require 'legion/extensions/global_workspace/helpers/constants'
5
+ require 'legion/extensions/global_workspace/helpers/broadcast'
6
+ require 'legion/extensions/global_workspace/helpers/competitor'
7
+ require 'legion/extensions/global_workspace/helpers/workspace'
8
+ require 'legion/extensions/global_workspace/runners/global_workspace'
9
+ require 'legion/extensions/global_workspace/client'
10
+
11
+ module Legion
12
+ module Extensions
13
+ module GlobalWorkspace
14
+ extend Legion::Extensions::Core if Legion::Extensions.const_defined? :Core
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::GlobalWorkspace::Client do
4
+ subject(:client) { described_class.new }
5
+
6
+ it 'includes Runners::GlobalWorkspace' do
7
+ expect(described_class.ancestors).to include(Legion::Extensions::GlobalWorkspace::Runners::GlobalWorkspace)
8
+ end
9
+
10
+ it 'supports full conscious access lifecycle' do
11
+ # Register subscribers (cognitive subsystems)
12
+ client.register_subscriber(id: :memory, name: 'lex-memory', domains: [:all])
13
+ client.register_subscriber(id: :emotion, name: 'lex-emotion', domains: [:safety])
14
+ client.register_subscriber(id: :planning, name: 'lex-planning', domains: [:cognition])
15
+
16
+ # Multiple subsystems submit content for conscious access
17
+ client.submit_for_broadcast(content: 'threat_nearby', source: :emotion, domain: :safety, salience: 0.85)
18
+ client.submit_for_broadcast(content: 'plan_step_3', source: :planning, domain: :cognition, salience: 0.5)
19
+ client.submit_for_broadcast(content: 'memory_trace', source: :memory, domain: :recall, salience: 0.3)
20
+
21
+ # Competition: emotion wins (highest salience, clear margin)
22
+ competition = client.run_competition
23
+ expect(competition[:success]).to be true
24
+ expect(competition[:broadcast][:content]).to eq('threat_nearby')
25
+
26
+ # Verify consciousness
27
+ conscious = client.query_consciousness(content: 'threat_nearby')
28
+ expect(conscious[:conscious]).to be true
29
+
30
+ # Subscribers acknowledge receipt
31
+ client.acknowledge_broadcast(subscriber_id: :memory)
32
+ client.acknowledge_broadcast(subscriber_id: :emotion)
33
+
34
+ # Check current state
35
+ current = client.current_broadcast
36
+ expect(current[:broadcast][:received_by]).to include(:memory, :emotion)
37
+
38
+ # Tick maintenance
39
+ client.update_global_workspace
40
+
41
+ # History preserved
42
+ history = client.broadcast_history(limit: 10)
43
+ expect(history[:total]).to eq(1)
44
+
45
+ # Stats
46
+ stats = client.workspace_stats
47
+ expect(stats[:stats][:subscribers]).to eq(3)
48
+ end
49
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::GlobalWorkspace::Helpers::Broadcast do
4
+ subject(:broadcast) do
5
+ described_class.new(content: 'threat_detected', source: :emotion, domain: :safety, salience: 0.9,
6
+ coalition: %i[fear attention])
7
+ end
8
+
9
+ let(:constants) { Legion::Extensions::GlobalWorkspace::Helpers::Constants }
10
+
11
+ describe '#initialize' do
12
+ it 'sets attributes' do
13
+ expect(broadcast.content).to eq('threat_detected')
14
+ expect(broadcast.source).to eq(:emotion)
15
+ expect(broadcast.domain).to eq(:safety)
16
+ expect(broadcast.salience).to eq(0.9)
17
+ expect(broadcast.coalition).to eq(%i[fear attention])
18
+ end
19
+
20
+ it 'clamps salience' do
21
+ high = described_class.new(content: :x, source: :s, domain: :d, salience: 1.5)
22
+ expect(high.salience).to eq(1.0)
23
+ end
24
+
25
+ it 'records broadcast_at' do
26
+ expect(broadcast.broadcast_at).to be_a(Time)
27
+ end
28
+
29
+ it 'starts with empty received_by' do
30
+ expect(broadcast.received_by).to be_empty
31
+ end
32
+
33
+ it 'limits coalition size' do
34
+ big = described_class.new(content: :x, source: :s, domain: :d, salience: 0.5,
35
+ coalition: (1..20).to_a)
36
+ expect(big.coalition.size).to eq(constants::MAX_COALITION_SIZE)
37
+ end
38
+ end
39
+
40
+ describe '#acknowledge' do
41
+ it 'adds subscriber to received_by' do
42
+ broadcast.acknowledge(:memory)
43
+ expect(broadcast.received_by).to include(:memory)
44
+ end
45
+
46
+ it 'does not duplicate' do
47
+ broadcast.acknowledge(:memory)
48
+ broadcast.acknowledge(:memory)
49
+ expect(broadcast.received_by.size).to eq(1)
50
+ end
51
+ end
52
+
53
+ describe '#expired?' do
54
+ it 'returns false when fresh' do
55
+ expect(broadcast.expired?).to be false
56
+ end
57
+ end
58
+
59
+ describe '#age' do
60
+ it 'returns elapsed time' do
61
+ expect(broadcast.age).to be >= 0.0
62
+ end
63
+ end
64
+
65
+ describe '#label' do
66
+ it 'returns :dominant for high salience' do
67
+ expect(broadcast.label).to eq(:dominant)
68
+ end
69
+
70
+ it 'returns :subliminal for low salience' do
71
+ low = described_class.new(content: :x, source: :s, domain: :d, salience: 0.1)
72
+ expect(low.label).to eq(:subliminal)
73
+ end
74
+ end
75
+
76
+ describe '#to_h' do
77
+ it 'returns hash with all fields' do
78
+ h = broadcast.to_h
79
+ expect(h).to include(:content, :source, :domain, :salience, :label, :coalition, :broadcast_at, :age,
80
+ :received_by, :expired)
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::GlobalWorkspace::Helpers::Competitor do
4
+ subject(:competitor) do
5
+ described_class.new(content: 'idea', source: :cortex, domain: :cognition, salience: 0.6, coalition: [:attention])
6
+ end
7
+
8
+ let(:constants) { Legion::Extensions::GlobalWorkspace::Helpers::Constants }
9
+
10
+ describe '#initialize' do
11
+ it 'sets attributes' do
12
+ expect(competitor.content).to eq('idea')
13
+ expect(competitor.source).to eq(:cortex)
14
+ expect(competitor.salience).to eq(0.6)
15
+ expect(competitor.urgency).to eq(0.0)
16
+ end
17
+
18
+ it 'clamps salience' do
19
+ high = described_class.new(content: :x, source: :s, domain: :d, salience: 2.0)
20
+ expect(high.salience).to eq(1.0)
21
+ end
22
+ end
23
+
24
+ describe '#effective_salience' do
25
+ it 'equals salience when no urgency' do
26
+ expect(competitor.effective_salience).to eq(0.6)
27
+ end
28
+
29
+ it 'includes urgency' do
30
+ competitor.urgency = 0.2
31
+ expect(competitor.effective_salience).to eq(0.8)
32
+ end
33
+
34
+ it 'clamps to 1.0' do
35
+ competitor.salience = 0.9
36
+ competitor.urgency = 0.3
37
+ expect(competitor.effective_salience).to eq(1.0)
38
+ end
39
+ end
40
+
41
+ describe '#decay' do
42
+ it 'reduces salience' do
43
+ before = competitor.salience
44
+ competitor.decay
45
+ expect(competitor.salience).to eq(before - constants::SALIENCE_DECAY)
46
+ end
47
+
48
+ it 'does not go below 0' do
49
+ competitor.salience = 0.01
50
+ competitor.decay
51
+ expect(competitor.salience).to eq(0.0)
52
+ end
53
+ end
54
+
55
+ describe '#boost_urgency' do
56
+ it 'increases urgency' do
57
+ competitor.boost_urgency
58
+ expect(competitor.urgency).to eq(constants::URGENCY_BOOST)
59
+ end
60
+
61
+ it 'caps at MAX_URGENCY' do
62
+ 50.times { competitor.boost_urgency }
63
+ expect(competitor.urgency).to eq(constants::MAX_URGENCY)
64
+ end
65
+ end
66
+
67
+ describe '#below_threshold?' do
68
+ it 'returns false when above threshold' do
69
+ expect(competitor.below_threshold?).to be false
70
+ end
71
+
72
+ it 'returns true when below threshold' do
73
+ competitor.salience = 0.1
74
+ expect(competitor.below_threshold?).to be true
75
+ end
76
+ end
77
+
78
+ describe '#to_h' do
79
+ it 'returns hash with all fields' do
80
+ h = competitor.to_h
81
+ expect(h).to include(:content, :source, :domain, :salience, :urgency, :effective_salience, :coalition)
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::GlobalWorkspace::Helpers::Workspace do
4
+ subject(:ws) { described_class.new }
5
+
6
+ let(:constants) { Legion::Extensions::GlobalWorkspace::Helpers::Constants }
7
+
8
+ describe '#register_subscriber / #unregister_subscriber' do
9
+ it 'registers a subscriber' do
10
+ expect(ws.register_subscriber(id: :memory, name: 'lex-memory', domains: [:all])).to be true
11
+ expect(ws.subscriber_count).to eq(1)
12
+ end
13
+
14
+ it 'unregisters a subscriber' do
15
+ ws.register_subscriber(id: :memory, name: 'lex-memory')
16
+ expect(ws.unregister_subscriber(id: :memory)).to be true
17
+ expect(ws.subscriber_count).to eq(0)
18
+ end
19
+
20
+ it 'returns false for unknown unregister' do
21
+ expect(ws.unregister_subscriber(id: :unknown)).to be false
22
+ end
23
+
24
+ it 'limits subscribers' do
25
+ constants::MAX_SUBSCRIBERS.times { |i| ws.register_subscriber(id: "sub_#{i}", name: "s#{i}") }
26
+ expect(ws.register_subscriber(id: :overflow, name: 'over')).to be false
27
+ end
28
+ end
29
+
30
+ describe '#submit' do
31
+ it 'adds a competitor' do
32
+ comp = ws.submit(content: 'idea', source: :cortex, domain: :cognition, salience: 0.6)
33
+ expect(comp).to be_a(Legion::Extensions::GlobalWorkspace::Helpers::Competitor)
34
+ expect(ws.competitor_count).to eq(1)
35
+ end
36
+
37
+ it 'rejects below-threshold submissions' do
38
+ result = ws.submit(content: 'weak', source: :s, domain: :d, salience: 0.1)
39
+ expect(result).to be_nil
40
+ expect(ws.competitor_count).to eq(0)
41
+ end
42
+
43
+ it 'limits competitor queue' do
44
+ constants::MAX_COMPETITORS.times { |i| ws.submit(content: "c_#{i}", source: :s, domain: :d, salience: 0.5) }
45
+ ws.submit(content: :overflow, source: :s, domain: :d, salience: 0.5)
46
+ expect(ws.competitor_count).to eq(constants::MAX_COMPETITORS)
47
+ end
48
+ end
49
+
50
+ describe '#compete' do
51
+ it 'returns nil with no competitors' do
52
+ expect(ws.compete).to be_nil
53
+ end
54
+
55
+ it 'broadcasts the dominant competitor' do
56
+ ws.submit(content: 'strong', source: :emotion, domain: :safety, salience: 0.9)
57
+ ws.submit(content: 'weak', source: :cortex, domain: :cognition, salience: 0.3)
58
+ broadcast = ws.compete
59
+ expect(broadcast).to be_a(Legion::Extensions::GlobalWorkspace::Helpers::Broadcast)
60
+ expect(broadcast.content).to eq('strong')
61
+ end
62
+
63
+ it 'returns nil when no clear winner (within margin)' do
64
+ ws.submit(content: 'a', source: :s, domain: :d, salience: 0.5)
65
+ ws.submit(content: 'b', source: :s, domain: :d, salience: 0.5)
66
+ result = ws.compete
67
+ expect(result).to be_nil
68
+ end
69
+
70
+ it 'boosts urgency of losers' do
71
+ ws.submit(content: 'a', source: :s, domain: :d, salience: 0.5)
72
+ ws.submit(content: 'b', source: :s, domain: :d, salience: 0.5)
73
+ ws.compete
74
+ expect(ws.competitors.first.urgency).to be > 0
75
+ end
76
+
77
+ it 'removes winner from competitors' do
78
+ ws.submit(content: 'winner', source: :s, domain: :d, salience: 0.9)
79
+ ws.compete
80
+ expect(ws.competitor_count).to eq(0)
81
+ end
82
+
83
+ it 'adds to broadcast history' do
84
+ ws.submit(content: 'winner', source: :s, domain: :d, salience: 0.9)
85
+ ws.compete
86
+ expect(ws.broadcast_history.size).to eq(1)
87
+ end
88
+ end
89
+
90
+ describe '#conscious?' do
91
+ it 'returns true for current broadcast content' do
92
+ ws.submit(content: 'idea', source: :s, domain: :d, salience: 0.9)
93
+ ws.compete
94
+ expect(ws.conscious?('idea')).to be true
95
+ end
96
+
97
+ it 'returns false when no broadcast' do
98
+ expect(ws.conscious?('anything')).to be false
99
+ end
100
+
101
+ it 'returns false for non-matching content' do
102
+ ws.submit(content: 'idea', source: :s, domain: :d, salience: 0.9)
103
+ ws.compete
104
+ expect(ws.conscious?('other')).to be false
105
+ end
106
+ end
107
+
108
+ describe '#current_content' do
109
+ it 'returns broadcast hash when active' do
110
+ ws.submit(content: 'idea', source: :cortex, domain: :cognition, salience: 0.9)
111
+ ws.compete
112
+ content = ws.current_content
113
+ expect(content[:content]).to eq('idea')
114
+ expect(content[:source]).to eq(:cortex)
115
+ end
116
+
117
+ it 'returns nil when no active broadcast' do
118
+ expect(ws.current_content).to be_nil
119
+ end
120
+ end
121
+
122
+ describe '#acknowledge' do
123
+ it 'acknowledges current broadcast' do
124
+ ws.submit(content: 'idea', source: :s, domain: :d, salience: 0.9)
125
+ ws.compete
126
+ expect(ws.acknowledge(subscriber_id: :memory)).to be true
127
+ end
128
+
129
+ it 'returns false when no broadcast' do
130
+ expect(ws.acknowledge(subscriber_id: :memory)).to be false
131
+ end
132
+ end
133
+
134
+ describe '#state' do
135
+ it 'returns :idle when empty' do
136
+ expect(ws.state).to eq(:idle)
137
+ end
138
+
139
+ it 'returns :broadcasting when broadcast active' do
140
+ ws.submit(content: 'idea', source: :s, domain: :d, salience: 0.9)
141
+ ws.compete
142
+ expect(ws.state).to eq(:broadcasting)
143
+ end
144
+
145
+ it 'returns :contention when multiple competitors' do
146
+ ws.submit(content: 'a', source: :s, domain: :d, salience: 0.5)
147
+ ws.submit(content: 'b', source: :s, domain: :d, salience: 0.5)
148
+ expect(ws.state).to eq(:contention)
149
+ end
150
+ end
151
+
152
+ describe '#tick' do
153
+ it 'decays competitors' do
154
+ ws.submit(content: 'idea', source: :s, domain: :d, salience: 0.5)
155
+ before = ws.competitors.first.salience
156
+ ws.tick
157
+ expect(ws.competitors.first&.salience || 0).to be <= before
158
+ end
159
+
160
+ it 'prunes weak competitors after decay' do
161
+ ws.submit(content: 'weak', source: :s, domain: :d, salience: constants::COMPETITION_THRESHOLD + 0.01)
162
+ ws.tick
163
+ expect(ws.competitor_count).to eq(0)
164
+ end
165
+ end
166
+
167
+ describe '#to_h' do
168
+ it 'returns stats hash' do
169
+ h = ws.to_h
170
+ expect(h).to include(:state, :state_label, :subscribers, :competitors, :broadcast_history, :utilization,
171
+ :competitions)
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::GlobalWorkspace::Runners::GlobalWorkspace do
4
+ let(:client) { Legion::Extensions::GlobalWorkspace::Client.new }
5
+
6
+ describe '#submit_for_broadcast' do
7
+ it 'submits content for competition' do
8
+ result = client.submit_for_broadcast(content: 'idea', source: :cortex, domain: :cognition, salience: 0.7)
9
+ expect(result[:success]).to be true
10
+ expect(result[:effective_salience]).to be > 0
11
+ end
12
+
13
+ it 'rejects below-threshold content' do
14
+ result = client.submit_for_broadcast(content: 'weak', source: :s, domain: :d, salience: 0.1)
15
+ expect(result[:success]).to be false
16
+ expect(result[:reason]).to eq(:below_threshold)
17
+ end
18
+ end
19
+
20
+ describe '#run_competition' do
21
+ it 'broadcasts winning content' do
22
+ client.submit_for_broadcast(content: 'strong', source: :emotion, domain: :safety, salience: 0.9)
23
+ result = client.run_competition
24
+ expect(result[:success]).to be true
25
+ expect(result[:broadcast][:content]).to eq('strong')
26
+ end
27
+
28
+ it 'returns no_winner when no competitors' do
29
+ result = client.run_competition
30
+ expect(result[:success]).to be false
31
+ expect(result[:reason]).to eq(:no_winner)
32
+ end
33
+ end
34
+
35
+ describe '#register_subscriber / #unregister_subscriber' do
36
+ it 'registers a subscriber' do
37
+ result = client.register_subscriber(id: :memory, name: 'lex-memory', domains: [:all])
38
+ expect(result[:success]).to be true
39
+ expect(result[:subscriber_count]).to eq(1)
40
+ end
41
+
42
+ it 'unregisters a subscriber' do
43
+ client.register_subscriber(id: :memory, name: 'lex-memory')
44
+ result = client.unregister_subscriber(id: :memory)
45
+ expect(result[:success]).to be true
46
+ end
47
+ end
48
+
49
+ describe '#acknowledge_broadcast' do
50
+ it 'acknowledges active broadcast' do
51
+ client.submit_for_broadcast(content: 'idea', source: :s, domain: :d, salience: 0.9)
52
+ client.run_competition
53
+ result = client.acknowledge_broadcast(subscriber_id: :memory)
54
+ expect(result[:success]).to be true
55
+ end
56
+
57
+ it 'returns false when no broadcast' do
58
+ result = client.acknowledge_broadcast(subscriber_id: :memory)
59
+ expect(result[:success]).to be false
60
+ end
61
+ end
62
+
63
+ describe '#query_consciousness' do
64
+ it 'returns conscious: true for broadcasted content' do
65
+ client.submit_for_broadcast(content: 'idea', source: :s, domain: :d, salience: 0.9)
66
+ client.run_competition
67
+ result = client.query_consciousness(content: 'idea')
68
+ expect(result[:conscious]).to be true
69
+ end
70
+
71
+ it 'returns conscious: false for non-broadcasted content' do
72
+ result = client.query_consciousness(content: 'nothing')
73
+ expect(result[:conscious]).to be false
74
+ end
75
+ end
76
+
77
+ describe '#current_broadcast' do
78
+ it 'returns broadcast when active' do
79
+ client.submit_for_broadcast(content: 'idea', source: :s, domain: :d, salience: 0.9)
80
+ client.run_competition
81
+ result = client.current_broadcast
82
+ expect(result[:broadcast]).not_to be_nil
83
+ expect(result[:broadcast][:content]).to eq('idea')
84
+ end
85
+
86
+ it 'returns nil when no active broadcast' do
87
+ result = client.current_broadcast
88
+ expect(result[:broadcast]).to be_nil
89
+ end
90
+ end
91
+
92
+ describe '#broadcast_history' do
93
+ it 'returns history' do
94
+ client.submit_for_broadcast(content: 'idea', source: :s, domain: :d, salience: 0.9)
95
+ client.run_competition
96
+ result = client.broadcast_history(limit: 5)
97
+ expect(result[:success]).to be true
98
+ expect(result[:history].size).to eq(1)
99
+ end
100
+ end
101
+
102
+ describe '#update_global_workspace' do
103
+ it 'runs tick maintenance' do
104
+ result = client.update_global_workspace
105
+ expect(result[:success]).to be true
106
+ expect(result).to have_key(:state)
107
+ expect(result).to have_key(:utilization)
108
+ end
109
+ end
110
+
111
+ describe '#workspace_stats' do
112
+ it 'returns stats' do
113
+ result = client.workspace_stats
114
+ expect(result[:success]).to be true
115
+ expect(result[:stats]).to include(:state, :subscribers, :competitors, :utilization)
116
+ end
117
+ end
118
+ 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/global_workspace'
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-global-workspace
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: Baars' Global Workspace Theory for brain-modeled agentic AI — information
27
+ competes for access to a limited-capacity workspace; winners are broadcast to all
28
+ subscribed cognitive subsystems, implementing a computational model of conscious
29
+ access and attentional bottleneck.
30
+ email:
31
+ - matthewdiverson@gmail.com
32
+ executables: []
33
+ extensions: []
34
+ extra_rdoc_files: []
35
+ files:
36
+ - Gemfile
37
+ - lex-global-workspace.gemspec
38
+ - lib/legion/extensions/global_workspace.rb
39
+ - lib/legion/extensions/global_workspace/actors/competition.rb
40
+ - lib/legion/extensions/global_workspace/client.rb
41
+ - lib/legion/extensions/global_workspace/helpers/broadcast.rb
42
+ - lib/legion/extensions/global_workspace/helpers/competitor.rb
43
+ - lib/legion/extensions/global_workspace/helpers/constants.rb
44
+ - lib/legion/extensions/global_workspace/helpers/workspace.rb
45
+ - lib/legion/extensions/global_workspace/runners/global_workspace.rb
46
+ - lib/legion/extensions/global_workspace/version.rb
47
+ - spec/legion/extensions/global_workspace/client_spec.rb
48
+ - spec/legion/extensions/global_workspace/helpers/broadcast_spec.rb
49
+ - spec/legion/extensions/global_workspace/helpers/competitor_spec.rb
50
+ - spec/legion/extensions/global_workspace/helpers/workspace_spec.rb
51
+ - spec/legion/extensions/global_workspace/runners/global_workspace_spec.rb
52
+ - spec/spec_helper.rb
53
+ homepage: https://github.com/LegionIO/lex-global-workspace
54
+ licenses:
55
+ - MIT
56
+ metadata:
57
+ homepage_uri: https://github.com/LegionIO/lex-global-workspace
58
+ source_code_uri: https://github.com/LegionIO/lex-global-workspace
59
+ documentation_uri: https://github.com/LegionIO/lex-global-workspace
60
+ changelog_uri: https://github.com/LegionIO/lex-global-workspace
61
+ bug_tracker_uri: https://github.com/LegionIO/lex-global-workspace/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 Global Workspace
80
+ test_files: []