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 +7 -0
- data/Gemfile +11 -0
- data/lex-joint-attention.gemspec +31 -0
- data/lib/legion/extensions/joint_attention/actors/decay.rb +41 -0
- data/lib/legion/extensions/joint_attention/client.rb +24 -0
- data/lib/legion/extensions/joint_attention/helpers/attention_target.rb +120 -0
- data/lib/legion/extensions/joint_attention/helpers/constants.rb +30 -0
- data/lib/legion/extensions/joint_attention/helpers/joint_focus_manager.rb +153 -0
- data/lib/legion/extensions/joint_attention/runners/joint_attention.rb +84 -0
- data/lib/legion/extensions/joint_attention/version.rb +9 -0
- data/lib/legion/extensions/joint_attention.rb +17 -0
- data/spec/legion/extensions/joint_attention/client_spec.rb +36 -0
- data/spec/legion/extensions/joint_attention/helpers/attention_target_spec.rb +258 -0
- data/spec/legion/extensions/joint_attention/helpers/joint_focus_manager_spec.rb +238 -0
- data/spec/legion/extensions/joint_attention/runners/joint_attention_spec.rb +228 -0
- data/spec/spec_helper.rb +20 -0
- metadata +77 -0
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,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,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
|