lex-joint-attention 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: 53f2d7e37654d603ad4e24112ae12264f4c2aa62b1d1f5f9afa33f533cfa79ad
4
+ data.tar.gz: ac7ad7cf0c6b56837d3f104621f41c39b3a7bfe9b83cf0eae8121dbf521e9736
5
+ SHA512:
6
+ metadata.gz: 7289dcb7ce22c9b3b9c7975d54de54149858b995d36f8d1f498d3867362f0de2cef29baaed01aa2d59eb8b00548280f6a5349ed624e6ea57f5089419d8dc6bab
7
+ data.tar.gz: 2141148896e3d02e86379e3919e6c701c6c685df2368ff9545526921e5921bb1b7f151941c970f8b086824996a8f2b7c0b3b7518db3a086237b1e557304d961f
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,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'lib/legion/extensions/joint_attention/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'lex-joint-attention'
7
+ spec.version = Legion::Extensions::JointAttention::VERSION
8
+ spec.authors = ['Esity']
9
+ spec.email = ['matthewdiverson@gmail.com']
10
+
11
+ spec.summary = 'LEX Joint Attention'
12
+ spec.description = "Tomasello's joint attention framework for LegionIO — shared focus between agents, " \
13
+ 'mutual awareness tracking, referential communication, and collaborative attention ' \
14
+ 'management with focus decay and working-memory constraints.'
15
+ spec.homepage = 'https://github.com/LegionIO/lex-joint-attention'
16
+ spec.license = 'MIT'
17
+ spec.required_ruby_version = '>= 3.4'
18
+
19
+ spec.metadata['homepage_uri'] = spec.homepage
20
+ spec.metadata['source_code_uri'] = 'https://github.com/LegionIO/lex-joint-attention'
21
+ spec.metadata['documentation_uri'] = 'https://github.com/LegionIO/lex-joint-attention'
22
+ spec.metadata['changelog_uri'] = 'https://github.com/LegionIO/lex-joint-attention'
23
+ spec.metadata['bug_tracker_uri'] = 'https://github.com/LegionIO/lex-joint-attention/issues'
24
+ spec.metadata['rubygems_mfa_required'] = 'true'
25
+
26
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
27
+ Dir.glob('{lib,spec}/**/*') + %w[lex-joint-attention.gemspec Gemfile]
28
+ end
29
+ spec.require_paths = ['lib']
30
+ spec.add_development_dependency 'legion-gaia'
31
+ 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 JointAttention
8
+ module Actor
9
+ class Decay < Legion::Extensions::Actors::Every
10
+ def runner_class
11
+ Legion::Extensions::JointAttention::Runners::JointAttention
12
+ end
13
+
14
+ def runner_function
15
+ 'update_joint_attention'
16
+ end
17
+
18
+ def time
19
+ 120
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,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/joint_attention/helpers/constants'
4
+ require 'legion/extensions/joint_attention/helpers/attention_target'
5
+ require 'legion/extensions/joint_attention/helpers/joint_focus_manager'
6
+ require 'legion/extensions/joint_attention/runners/joint_attention'
7
+
8
+ module Legion
9
+ module Extensions
10
+ module JointAttention
11
+ class Client
12
+ include Runners::JointAttention
13
+
14
+ def initialize(joint_focus_manager: nil, **)
15
+ @joint_focus_manager = joint_focus_manager || Helpers::JointFocusManager.new
16
+ end
17
+
18
+ private
19
+
20
+ attr_reader :joint_focus_manager
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module JointAttention
6
+ module Helpers
7
+ class AttentionTarget
8
+ include Constants
9
+
10
+ attr_reader :id, :name, :domain, :priority, :creator_agent_id, :created_at, :attendees, :focus_strength
11
+
12
+ def initialize(name:, domain:, priority:, creator_agent_id:)
13
+ @id = SecureRandom.uuid
14
+ @name = name
15
+ @domain = domain
16
+ @priority = priority.clamp(0.0, 1.0)
17
+ @creator_agent_id = creator_agent_id
18
+ @created_at = Time.now.utc
19
+ @attendees = {}
20
+ @focus_strength = DEFAULT_FOCUS
21
+ end
22
+
23
+ def add_attendee(agent_id:, gaze: nil)
24
+ return :at_capacity if @attendees.size >= MAX_ATTENDEES_PER_TARGET
25
+ return :already_attending if @attendees.key?(agent_id)
26
+
27
+ @attendees[agent_id] = {
28
+ focus: DEFAULT_FOCUS,
29
+ gaze: gaze,
30
+ joined_at: Time.now.utc,
31
+ mutual_awareness: false
32
+ }
33
+ :joined
34
+ end
35
+
36
+ def remove_attendee(agent_id:)
37
+ return :not_found unless @attendees.key?(agent_id)
38
+
39
+ @attendees.delete(agent_id)
40
+ :removed
41
+ end
42
+
43
+ def update_gaze(agent_id:, gaze:)
44
+ return :not_found unless @attendees.key?(agent_id)
45
+
46
+ @attendees[agent_id][:gaze] = gaze
47
+ :updated
48
+ end
49
+
50
+ def boost_focus(agent_id:, amount:)
51
+ return :not_found unless @attendees.key?(agent_id)
52
+
53
+ current = @attendees[agent_id][:focus]
54
+ @attendees[agent_id][:focus] = [current + amount, 1.0].min
55
+ :boosted
56
+ end
57
+
58
+ def establish_mutual_awareness(agent_a:, agent_b:)
59
+ return :not_found unless @attendees.key?(agent_a) && @attendees.key?(agent_b)
60
+
61
+ bonus = SHARED_AWARENESS_BONUS
62
+ @attendees[agent_a][:mutual_awareness] = true
63
+ @attendees[agent_b][:mutual_awareness] = true
64
+ @attendees[agent_a][:focus] = [@attendees[agent_a][:focus] + bonus, 1.0].min
65
+ @attendees[agent_b][:focus] = [@attendees[agent_b][:focus] + bonus, 1.0].min
66
+ :established
67
+ end
68
+
69
+ def attendee_count
70
+ @attendees.size
71
+ end
72
+
73
+ def shared_awareness?
74
+ @attendees.count { |_, v| v[:mutual_awareness] } >= 2
75
+ end
76
+
77
+ def focus_label
78
+ FOCUS_LABELS.each { |range, lbl| return lbl if range.cover?(@focus_strength) }
79
+ :fading
80
+ end
81
+
82
+ def decay
83
+ @attendees.each_value do |info|
84
+ info[:focus] = [info[:focus] - FOCUS_DECAY, FOCUS_FLOOR].max
85
+ end
86
+ @focus_strength = if @attendees.empty?
87
+ [@focus_strength - FOCUS_DECAY, FOCUS_FLOOR].max
88
+ else
89
+ @attendees.values.sum { |v| v[:focus] } / @attendees.size
90
+ end
91
+ end
92
+
93
+ def faded?
94
+ @focus_strength <= FOCUS_FLOOR && @attendees.empty?
95
+ end
96
+
97
+ def prune_faded_attendees
98
+ @attendees.reject! { |_, v| v[:focus] <= FOCUS_FLOOR }
99
+ end
100
+
101
+ def to_h
102
+ {
103
+ id: @id,
104
+ name: @name,
105
+ domain: @domain,
106
+ priority: @priority,
107
+ creator_agent_id: @creator_agent_id,
108
+ created_at: @created_at,
109
+ focus_strength: @focus_strength.round(4),
110
+ focus_label: focus_label,
111
+ attendee_count: attendee_count,
112
+ shared_awareness: shared_awareness?,
113
+ attendees: @attendees.transform_values { |v| v.merge(focus: v[:focus].round(4)) }
114
+ }
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module JointAttention
6
+ module Helpers
7
+ module Constants
8
+ MAX_TARGETS = 100
9
+ MAX_ATTENDEES_PER_TARGET = 20
10
+ MAX_HISTORY = 200
11
+ FOCUS_DECAY = 0.015
12
+ FOCUS_FLOOR = 0.05
13
+ FOCUS_ALPHA = 0.12
14
+ DEFAULT_FOCUS = 0.5
15
+ SHARED_AWARENESS_BONUS = 0.15
16
+ REFERRAL_BOOST = 0.2
17
+ MAX_SIMULTANEOUS_TARGETS = 5
18
+
19
+ FOCUS_LABELS = {
20
+ (0.8..) => :locked_on,
21
+ (0.6...0.8) => :focused,
22
+ (0.4...0.6) => :attending,
23
+ (0.2...0.4) => :peripheral,
24
+ (..0.2) => :fading
25
+ }.freeze
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module JointAttention
6
+ module Helpers
7
+ class JointFocusManager
8
+ include Constants
9
+
10
+ attr_reader :targets, :history
11
+
12
+ def initialize
13
+ @targets = {}
14
+ @agent_focus = {}
15
+ @history = []
16
+ end
17
+
18
+ def create_target(name:, domain:, priority:, creator:)
19
+ prune_targets if @targets.size >= MAX_TARGETS
20
+ target = AttentionTarget.new(
21
+ name: name,
22
+ domain: domain,
23
+ priority: priority,
24
+ creator_agent_id: creator
25
+ )
26
+ @targets[target.id] = target
27
+ record_history(event: :target_created, target_id: target.id, agent_id: creator)
28
+ join_target(target_id: target.id, agent_id: creator)
29
+ target
30
+ end
31
+
32
+ def join_target(target_id:, agent_id:, gaze: nil)
33
+ target = @targets[target_id]
34
+ return :target_not_found unless target
35
+
36
+ current = agent_target_ids(agent_id)
37
+ return :capacity_exceeded if current.size >= MAX_SIMULTANEOUS_TARGETS && !current.include?(target_id)
38
+
39
+ result = target.add_attendee(agent_id: agent_id, gaze: gaze)
40
+ if result == :joined
41
+ @agent_focus[agent_id] ||= []
42
+ @agent_focus[agent_id] << target_id unless @agent_focus[agent_id].include?(target_id)
43
+ record_history(event: :agent_joined, target_id: target_id, agent_id: agent_id)
44
+ end
45
+ result
46
+ end
47
+
48
+ def leave_target(target_id:, agent_id:)
49
+ target = @targets[target_id]
50
+ return :target_not_found unless target
51
+
52
+ result = target.remove_attendee(agent_id: agent_id)
53
+ if result == :removed
54
+ @agent_focus[agent_id]&.delete(target_id)
55
+ record_history(event: :agent_left, target_id: target_id, agent_id: agent_id)
56
+ end
57
+ result
58
+ end
59
+
60
+ def direct_attention(from_agent:, to_agent:, target_id:)
61
+ target = @targets[target_id]
62
+ return :target_not_found unless target
63
+
64
+ return :referrer_not_attending unless target.attendees.key?(from_agent)
65
+
66
+ join_result = join_target(target_id: target_id, agent_id: to_agent)
67
+ return join_result if join_result == :capacity_exceeded
68
+
69
+ target.boost_focus(agent_id: to_agent, amount: REFERRAL_BOOST) if target.attendees.key?(to_agent)
70
+ record_history(event: :attention_directed, target_id: target_id, from_agent: from_agent, to_agent: to_agent)
71
+ :directed
72
+ end
73
+
74
+ def establish_shared(target_id:, agent_a:, agent_b:)
75
+ target = @targets[target_id]
76
+ return :target_not_found unless target
77
+
78
+ result = target.establish_mutual_awareness(agent_a: agent_a, agent_b: agent_b)
79
+ record_history(event: :shared_awareness, target_id: target_id, agent_a: agent_a, agent_b: agent_b) if result == :established
80
+ result
81
+ end
82
+
83
+ def update_gaze(target_id:, agent_id:, gaze:)
84
+ target = @targets[target_id]
85
+ return :target_not_found unless target
86
+
87
+ target.update_gaze(agent_id: agent_id, gaze: gaze)
88
+ end
89
+
90
+ def targets_for_agent(agent_id:)
91
+ ids = agent_target_ids(agent_id)
92
+ ids.filter_map { |tid| @targets[tid] }
93
+ end
94
+
95
+ def attendees_for_target(target_id:)
96
+ target = @targets[target_id]
97
+ return [] unless target
98
+
99
+ target.attendees.keys
100
+ end
101
+
102
+ def shared_targets(agent_a:, agent_b:)
103
+ ids_a = agent_target_ids(agent_a)
104
+ ids_b = agent_target_ids(agent_b)
105
+ (ids_a & ids_b).filter_map { |tid| @targets[tid] }
106
+ end
107
+
108
+ def decay_all
109
+ @targets.each_value(&:decay)
110
+ @targets.each_value(&:prune_faded_attendees)
111
+ sync_agent_focus
112
+ @targets.reject! { |_, t| t.faded? }
113
+ end
114
+
115
+ def target_count
116
+ @targets.size
117
+ end
118
+
119
+ def to_h
120
+ {
121
+ target_count: target_count,
122
+ agent_count: @agent_focus.count { |_, ids| ids.any? },
123
+ history_size: @history.size,
124
+ targets: @targets.transform_values(&:to_h)
125
+ }
126
+ end
127
+
128
+ private
129
+
130
+ def agent_target_ids(agent_id)
131
+ @agent_focus.fetch(agent_id, [])
132
+ end
133
+
134
+ def sync_agent_focus
135
+ @agent_focus.each_value { |ids| ids.select! { |tid| @targets.key?(tid) } }
136
+ end
137
+
138
+ def record_history(event)
139
+ @history << event.merge(at: Time.now.utc)
140
+ @history.shift while @history.size > MAX_HISTORY
141
+ end
142
+
143
+ def prune_targets
144
+ sorted = @targets.values.sort_by { |t| [t.focus_strength, t.created_at] }
145
+ to_remove = sorted.first(@targets.size - MAX_TARGETS + 1)
146
+ to_remove.each { |t| @targets.delete(t.id) }
147
+ sync_agent_focus
148
+ end
149
+ end
150
+ end
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module JointAttention
6
+ module Runners
7
+ module JointAttention
8
+ include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers) &&
9
+ Legion::Extensions::Helpers.const_defined?(:Lex)
10
+
11
+ def create_attention_target(name:, domain:, creator:, priority: 0.5, **)
12
+ target = joint_focus_manager.create_target(name: name, domain: domain, priority: priority, creator: creator)
13
+ Legion::Logging.debug "[joint_attention] create_target: name=#{name} domain=#{domain} priority=#{priority} " \
14
+ "creator=#{creator} id=#{target.id}"
15
+ { success: true, target: target.to_h }
16
+ end
17
+
18
+ def join_attention(target_id:, agent_id:, gaze: nil, **)
19
+ result = joint_focus_manager.join_target(target_id: target_id, agent_id: agent_id, gaze: gaze)
20
+ Legion::Logging.debug "[joint_attention] join: target_id=#{target_id} agent_id=#{agent_id} gaze=#{gaze} result=#{result}"
21
+ { success: %i[joined already_attending].include?(result), result: result, target_id: target_id, agent_id: agent_id }
22
+ end
23
+
24
+ def leave_attention(target_id:, agent_id:, **)
25
+ result = joint_focus_manager.leave_target(target_id: target_id, agent_id: agent_id)
26
+ Legion::Logging.debug "[joint_attention] leave: target_id=#{target_id} agent_id=#{agent_id} result=#{result}"
27
+ { success: result == :removed, result: result, target_id: target_id, agent_id: agent_id }
28
+ end
29
+
30
+ def direct_attention(from_agent:, to_agent:, target_id:, **)
31
+ result = joint_focus_manager.direct_attention(from_agent: from_agent, to_agent: to_agent, target_id: target_id)
32
+ Legion::Logging.debug "[joint_attention] direct: from=#{from_agent} to=#{to_agent} target_id=#{target_id} result=#{result}"
33
+ { success: %i[directed already_attending].include?(result), result: result, target_id: target_id,
34
+ from_agent: from_agent, to_agent: to_agent }
35
+ end
36
+
37
+ def establish_mutual_awareness(target_id:, agent_a:, agent_b:, **)
38
+ result = joint_focus_manager.establish_shared(target_id: target_id, agent_a: agent_a, agent_b: agent_b)
39
+ Legion::Logging.debug "[joint_attention] mutual_awareness: target_id=#{target_id} " \
40
+ "agent_a=#{agent_a} agent_b=#{agent_b} result=#{result}"
41
+ { success: result == :established, result: result, target_id: target_id, agent_a: agent_a, agent_b: agent_b }
42
+ end
43
+
44
+ def update_gaze(target_id:, agent_id:, gaze:, **)
45
+ result = joint_focus_manager.update_gaze(target_id: target_id, agent_id: agent_id, gaze: gaze)
46
+ Legion::Logging.debug "[joint_attention] update_gaze: target_id=#{target_id} agent_id=#{agent_id} gaze=#{gaze} result=#{result}"
47
+ { success: result == :updated, result: result, target_id: target_id, agent_id: agent_id, gaze: gaze }
48
+ end
49
+
50
+ def shared_focus(agent_a:, agent_b:, **)
51
+ targets = joint_focus_manager.shared_targets(agent_a: agent_a, agent_b: agent_b)
52
+ Legion::Logging.debug "[joint_attention] shared_focus: agent_a=#{agent_a} agent_b=#{agent_b} count=#{targets.size}"
53
+ { success: true, agent_a: agent_a, agent_b: agent_b, shared_targets: targets.map(&:to_h), count: targets.size }
54
+ end
55
+
56
+ def attention_targets_for(agent_id:, **)
57
+ targets = joint_focus_manager.targets_for_agent(agent_id: agent_id)
58
+ Legion::Logging.debug "[joint_attention] targets_for: agent_id=#{agent_id} count=#{targets.size}"
59
+ { success: true, agent_id: agent_id, targets: targets.map(&:to_h), count: targets.size }
60
+ end
61
+
62
+ def update_joint_attention(**)
63
+ joint_focus_manager.decay_all
64
+ stats = joint_focus_manager.to_h
65
+ Legion::Logging.debug "[joint_attention] decay_tick: targets=#{stats[:target_count]} agents=#{stats[:agent_count]}"
66
+ { success: true, targets: stats[:target_count], agents: stats[:agent_count], history: stats[:history_size] }
67
+ end
68
+
69
+ def joint_attention_stats(**)
70
+ stats = joint_focus_manager.to_h
71
+ Legion::Logging.debug "[joint_attention] stats: targets=#{stats[:target_count]} agents=#{stats[:agent_count]}"
72
+ { success: true, stats: stats }
73
+ end
74
+
75
+ private
76
+
77
+ def joint_focus_manager
78
+ @joint_focus_manager ||= Helpers::JointFocusManager.new
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module JointAttention
6
+ VERSION = '0.1.0'
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+ require 'legion/extensions/joint_attention/version'
5
+ require 'legion/extensions/joint_attention/helpers/constants'
6
+ require 'legion/extensions/joint_attention/helpers/attention_target'
7
+ require 'legion/extensions/joint_attention/helpers/joint_focus_manager'
8
+ require 'legion/extensions/joint_attention/runners/joint_attention'
9
+ require 'legion/extensions/joint_attention/client'
10
+
11
+ module Legion
12
+ module Extensions
13
+ module JointAttention
14
+ extend Legion::Extensions::Core if Legion::Extensions.const_defined? :Core
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::JointAttention::Client do
4
+ subject(:client) { described_class.new }
5
+
6
+ it 'instantiates without arguments' do
7
+ expect(client).to be_a(described_class)
8
+ end
9
+
10
+ it 'accepts an injected joint_focus_manager' do
11
+ mgr = Legion::Extensions::JointAttention::Helpers::JointFocusManager.new
12
+ c = described_class.new(joint_focus_manager: mgr)
13
+ expect(c).to be_a(described_class)
14
+ end
15
+
16
+ it 'includes runner methods' do
17
+ expect(client).to respond_to(:create_attention_target)
18
+ expect(client).to respond_to(:join_attention)
19
+ expect(client).to respond_to(:leave_attention)
20
+ expect(client).to respond_to(:direct_attention)
21
+ expect(client).to respond_to(:establish_mutual_awareness)
22
+ expect(client).to respond_to(:update_gaze)
23
+ expect(client).to respond_to(:shared_focus)
24
+ expect(client).to respond_to(:attention_targets_for)
25
+ expect(client).to respond_to(:update_joint_attention)
26
+ expect(client).to respond_to(:joint_attention_stats)
27
+ end
28
+
29
+ it 'two clients maintain independent state' do
30
+ c1 = described_class.new
31
+ c2 = described_class.new
32
+ c1.create_attention_target(name: 'only-in-c1', domain: :test, creator: 'agent-1')
33
+ expect(c1.joint_attention_stats[:stats][:target_count]).to eq(1)
34
+ expect(c2.joint_attention_stats[:stats][:target_count]).to eq(0)
35
+ end
36
+ end