lex-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: 5881f7686d12eba793b2952b24ad0c7c4fe67588d22640abc144e0744897d790
4
+ data.tar.gz: 924c52406ed8d9cf75da4ba6caf78d89dc56c057901c9977e1f63162cd899c91
5
+ SHA512:
6
+ metadata.gz: 3d5eabbff72fad98045ddb012f26cea8291eed742277e329e83b74bc7412b625dc0651be523286c9d7f157a28ee2e893f22feca7623171930f485237be0b9462
7
+ data.tar.gz: d70ff18a5153263d6788343abb36ccee991bc8bfd588e371a60ab259aecdb79c1322485e45bc53a5e18986ac1593247d7e5b3a3fc2da2452833feea7209016f7
data/Gemfile ADDED
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+ gemspec
5
+
6
+ group :test do
7
+ gem 'rake'
8
+ gem 'rspec'
9
+ gem 'rspec_junit_formatter'
10
+ gem 'rubocop', require: false
11
+ gem 'rubocop-rspec', require: false
12
+ gem 'simplecov'
13
+ end
14
+
15
+ gem 'legion-gaia', path: '../../legion-gaia'
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Esity
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,56 @@
1
+ # lex-attention
2
+
3
+ Selective attention filter for the LegionIO brain-modeled cognitive architecture.
4
+
5
+ ## What It Does
6
+
7
+ Models the thalamic reticular nucleus and prefrontal attention systems. Filters and prioritizes incoming signals before they enter the cognitive pipeline. Implements spotlight attention, goal-directed amplification, habituation, and capacity limits.
8
+
9
+ ```ruby
10
+ client = Legion::Extensions::Attention::Client.new
11
+
12
+ # Filter incoming signals
13
+ result = client.filter_signals(
14
+ signals: [
15
+ { salience: 0.9, domain: :terraform, novelty: 0.8 },
16
+ { salience: 0.1, domain: :heartbeat, novelty: 0.0 },
17
+ { salience: 0.5, domain: :vault, novelty: 0.6 }
18
+ ],
19
+ active_wonders: [
20
+ { domain: :terraform, salience: 0.7 }
21
+ ]
22
+ )
23
+ # => { filtered: [...], spotlight: 1, peripheral: 1, background: 0, dropped: 1 }
24
+
25
+ # Manual focus
26
+ client.focus_on(domain: :security, reason: 'incident response')
27
+ client.attention_status
28
+ ```
29
+
30
+ ## Attention Tiers
31
+
32
+ | Tier | Score Range | Meaning |
33
+ |------|------------|---------|
34
+ | `:spotlight` | >= 0.7 | Full cognitive processing |
35
+ | `:peripheral` | >= 0.4 | Reduced processing, may promote |
36
+ | `:background` | >= 0.2 | Minimal processing, logged |
37
+ | `:filtered` | < 0.2 | Dropped entirely |
38
+
39
+ ## Key Features
40
+
41
+ - **Habituation**: Repeated signals in same domain become less salient
42
+ - **Goal amplification**: Signals related to active curiosity wonders get boosted
43
+ - **Capacity limit**: Max 7 spotlight items (Miller's law)
44
+ - **Manual focus**: Direct attention to specific domains
45
+
46
+ ## Development
47
+
48
+ ```bash
49
+ bundle install
50
+ bundle exec rspec
51
+ bundle exec rubocop
52
+ ```
53
+
54
+ ## License
55
+
56
+ MIT
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'lib/legion/extensions/attention/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'lex-attention'
7
+ spec.version = Legion::Extensions::Attention::VERSION
8
+ spec.authors = ['Esity']
9
+ spec.email = ['matthewdiverson@gmail.com']
10
+
11
+ spec.summary = 'LEX Attention'
12
+ spec.description = 'Selective attention filter for brain-modeled agentic AI'
13
+ spec.homepage = 'https://github.com/LegionIO/lex-attention'
14
+ spec.license = 'MIT'
15
+ spec.required_ruby_version = '>= 3.4'
16
+
17
+ spec.metadata['homepage_uri'] = spec.homepage
18
+ spec.metadata['source_code_uri'] = 'https://github.com/LegionIO/lex-attention'
19
+ spec.metadata['documentation_uri'] = 'https://github.com/LegionIO/lex-attention'
20
+ spec.metadata['changelog_uri'] = 'https://github.com/LegionIO/lex-attention'
21
+ spec.metadata['bug_tracker_uri'] = 'https://github.com/LegionIO/lex-attention/issues'
22
+ spec.metadata['rubygems_mfa_required'] = 'true'
23
+
24
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
25
+ Dir.glob('{lib,spec}/**/*') + %w[lex-attention.gemspec Gemfile LICENSE README.md]
26
+ end
27
+ spec.require_paths = ['lib']
28
+ spec.add_development_dependency 'legion-gaia'
29
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/attention/helpers/constants'
4
+ require 'legion/extensions/attention/helpers/focus'
5
+ require 'legion/extensions/attention/helpers/focus_manager'
6
+ require 'legion/extensions/attention/helpers/habituation'
7
+ require 'legion/extensions/attention/runners/attention'
8
+
9
+ module Legion
10
+ module Extensions
11
+ module Attention
12
+ class Client
13
+ include Runners::Attention
14
+
15
+ def initialize(**)
16
+ @focus_manager = Helpers::FocusManager.new
17
+ @habituation_model = Helpers::Habituation.new
18
+ end
19
+
20
+ private
21
+
22
+ attr_reader :focus_manager, :habituation_model
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Attention
6
+ module Helpers
7
+ module Constants
8
+ # Miller's law: 7 +/- 2 simultaneous attention targets
9
+ ATTENTIONAL_CAPACITY = 7
10
+
11
+ # Score weights
12
+ INTRINSIC_WEIGHT = 0.3
13
+ GOAL_RELEVANCE_WEIGHT = 0.3
14
+ NOVELTY_WEIGHT = 0.2
15
+ HABITUATION_WEIGHT = 0.2
16
+
17
+ # Habituation
18
+ HABITUATION_RATE = 0.15 # how fast habituation builds per encounter
19
+ HABITUATION_DECAY = 0.05 # how fast habituation fades per tick without signal
20
+ HABITUATION_CEILING = 0.95 # max habituation (never fully ignore)
21
+ NOVELTY_RESET_FACTOR = 0.3 # how much novelty reduces habituation
22
+
23
+ # Filtering thresholds
24
+ BACKGROUND_THRESHOLD = 0.2 # below this, signal is tagged :background
25
+ MINIMUM_THRESHOLD = 0.05 # below this, signal is dropped entirely
26
+
27
+ # Focus
28
+ MAX_MANUAL_FOCUS = 3 # max manually focused domains
29
+ FOCUS_BOOST = 0.3 # attention boost for manually focused domains
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Attention
6
+ module Helpers
7
+ module Focus
8
+ module_function
9
+
10
+ def score_signal(signal, habituation_level: 0.0, goal_relevance: 0.0)
11
+ intrinsic = extract_salience(signal)
12
+ novelty = extract_novelty(signal)
13
+
14
+ weighted = (intrinsic * Constants::INTRINSIC_WEIGHT) +
15
+ (goal_relevance * Constants::GOAL_RELEVANCE_WEIGHT) +
16
+ (novelty * Constants::NOVELTY_WEIGHT) +
17
+ ((1.0 - habituation_level) * Constants::HABITUATION_WEIGHT)
18
+
19
+ weighted.clamp(0.0, 1.0)
20
+ end
21
+
22
+ def extract_salience(signal)
23
+ return 0.0 unless signal.is_a?(Hash)
24
+
25
+ signal[:salience] || signal[:attention_score] || 0.0
26
+ end
27
+
28
+ def extract_novelty(signal)
29
+ return 0.5 unless signal.is_a?(Hash)
30
+
31
+ signal[:novelty] || 0.5
32
+ end
33
+
34
+ def extract_domain(signal)
35
+ return :general unless signal.is_a?(Hash)
36
+
37
+ signal[:domain] || signal[:source_type] || :general
38
+ end
39
+
40
+ def tag_signal(signal, attention_score:, attention_tier:)
41
+ return signal unless signal.is_a?(Hash)
42
+
43
+ signal.merge(attention_score: attention_score, attention_tier: attention_tier)
44
+ end
45
+
46
+ def attention_tier(score)
47
+ if score >= 0.7 then :spotlight
48
+ elsif score >= 0.4 then :peripheral
49
+ elsif score >= Constants::BACKGROUND_THRESHOLD then :background
50
+ else :filtered
51
+ end
52
+ end
53
+
54
+ def deduplicate(signals)
55
+ seen = {}
56
+ signals.each_with_object([]) do |signal, unique|
57
+ key = signal_identity(signal)
58
+ unless seen[key]
59
+ seen[key] = true
60
+ unique << signal
61
+ end
62
+ end
63
+ end
64
+
65
+ def signal_identity(signal)
66
+ return signal.object_id unless signal.is_a?(Hash)
67
+
68
+ [signal[:source_type], signal[:domain], signal[:value]].hash
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Attention
6
+ module Helpers
7
+ class FocusManager
8
+ attr_reader :manual_focus
9
+
10
+ def initialize
11
+ @manual_focus = {}
12
+ end
13
+
14
+ def focus_on(domain, reason: nil)
15
+ domain = domain.to_sym
16
+ return :already_focused if @manual_focus.key?(domain)
17
+ return :capacity_full if @manual_focus.size >= Constants::MAX_MANUAL_FOCUS
18
+
19
+ @manual_focus[domain] = { reason: reason, focused_at: Time.now.utc }
20
+ :focused
21
+ end
22
+
23
+ def release(domain)
24
+ domain = domain.to_sym
25
+ return :not_focused unless @manual_focus.key?(domain)
26
+
27
+ @manual_focus.delete(domain)
28
+ :released
29
+ end
30
+
31
+ def focused?(domain)
32
+ @manual_focus.key?(domain.to_sym)
33
+ end
34
+
35
+ def focus_boost(domain)
36
+ focused?(domain) ? Constants::FOCUS_BOOST : 0.0
37
+ end
38
+
39
+ def goal_relevance(signal, active_wonders: [])
40
+ domain = Focus.extract_domain(signal)
41
+ base_relevance = focus_boost(domain)
42
+
43
+ wonder_relevance = compute_wonder_relevance(signal, active_wonders)
44
+ [base_relevance + wonder_relevance, 1.0].min
45
+ end
46
+
47
+ private
48
+
49
+ def compute_wonder_relevance(signal, active_wonders)
50
+ return 0.0 if active_wonders.empty?
51
+
52
+ domain = Focus.extract_domain(signal)
53
+ matching = active_wonders.select { |w| w.is_a?(Hash) && w[:domain] == domain }
54
+ return 0.0 if matching.empty?
55
+
56
+ max_score = matching.map { |w| w[:salience] || 0.5 }.max
57
+ max_score * 0.5
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Attention
6
+ module Helpers
7
+ class Habituation
8
+ def initialize
9
+ @domain_exposure = Hash.new(0.0)
10
+ @domain_last_seen = {}
11
+ end
12
+
13
+ def record(domain)
14
+ domain = domain.to_sym
15
+ @domain_exposure[domain] = [
16
+ @domain_exposure[domain] + Constants::HABITUATION_RATE,
17
+ Constants::HABITUATION_CEILING
18
+ ].min
19
+ @domain_last_seen[domain] = Time.now.utc
20
+ end
21
+
22
+ def level(domain)
23
+ domain = domain.to_sym
24
+ @domain_exposure[domain]
25
+ end
26
+
27
+ def apply_novelty_reset(domain, novelty: 0.5)
28
+ domain = domain.to_sym
29
+ return unless novelty > 0.5
30
+
31
+ reduction = (novelty - 0.5) * Constants::NOVELTY_RESET_FACTOR
32
+ @domain_exposure[domain] = [@domain_exposure[domain] - reduction, 0.0].max
33
+ end
34
+
35
+ def decay_all
36
+ @domain_exposure.each_key do |domain|
37
+ @domain_exposure[domain] = [
38
+ @domain_exposure[domain] - Constants::HABITUATION_DECAY,
39
+ 0.0
40
+ ].max
41
+ end
42
+
43
+ @domain_exposure.delete_if { |_, v| v <= 0.0 }
44
+ end
45
+
46
+ def stats
47
+ @domain_exposure.transform_values { |v| v.round(3) }
48
+ end
49
+
50
+ def habituated_domains(threshold: 0.5)
51
+ @domain_exposure.select { |_, v| v >= threshold }.keys
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Attention
6
+ module Runners
7
+ module Attention
8
+ include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers) &&
9
+ Legion::Extensions::Helpers.const_defined?(:Lex)
10
+
11
+ def filter_signals(signals: [], active_wonders: [], **)
12
+ return { filtered: [], spotlight: 0, peripheral: 0, background: 0, dropped: 0 } if signals.empty?
13
+
14
+ unique = Helpers::Focus.deduplicate(signals)
15
+ scored = score_all(unique, active_wonders)
16
+ categorized = categorize(scored)
17
+
18
+ habituation_model.decay_all
19
+
20
+ Legion::Logging.debug "[attention] filtered #{signals.size}->#{categorized[:spotlight].size + categorized[:peripheral].size} " \
21
+ "(spotlight=#{categorized[:spotlight].size} peripheral=#{categorized[:peripheral].size} " \
22
+ "background=#{categorized[:background].size} dropped=#{categorized[:dropped]})"
23
+
24
+ {
25
+ filtered: categorized[:spotlight] + categorized[:peripheral],
26
+ spotlight: categorized[:spotlight].size,
27
+ peripheral: categorized[:peripheral].size,
28
+ background: categorized[:background].size,
29
+ dropped: categorized[:dropped]
30
+ }
31
+ end
32
+
33
+ def attention_status(**)
34
+ {
35
+ manual_focus: focus_manager.manual_focus,
36
+ habituated_domains: habituation_model.habituated_domains,
37
+ habituation_stats: habituation_model.stats,
38
+ capacity: Helpers::Constants::ATTENTIONAL_CAPACITY,
39
+ focus_count: focus_manager.manual_focus.size
40
+ }
41
+ end
42
+
43
+ def focus_on(domain:, reason: nil, **)
44
+ result = focus_manager.focus_on(domain, reason: reason)
45
+ Legion::Logging.info "[attention] focus_on #{domain}: #{result}"
46
+ { status: result, domain: domain }
47
+ end
48
+
49
+ def release_focus(domain:, **)
50
+ result = focus_manager.release(domain)
51
+ Legion::Logging.info "[attention] release_focus #{domain}: #{result}"
52
+ { status: result, domain: domain }
53
+ end
54
+
55
+ def habituation_stats(**)
56
+ { domains: habituation_model.stats, habituated: habituation_model.habituated_domains }
57
+ end
58
+
59
+ private
60
+
61
+ def focus_manager
62
+ @focus_manager ||= Helpers::FocusManager.new
63
+ end
64
+
65
+ def habituation_model
66
+ @habituation_model ||= Helpers::Habituation.new
67
+ end
68
+
69
+ def score_all(signals, active_wonders)
70
+ signals.map do |signal|
71
+ domain = Helpers::Focus.extract_domain(signal)
72
+ habituation_model.record(domain)
73
+
74
+ novelty = Helpers::Focus.extract_novelty(signal)
75
+ habituation_model.apply_novelty_reset(domain, novelty: novelty)
76
+
77
+ goal_rel = focus_manager.goal_relevance(signal, active_wonders: active_wonders)
78
+ hab_level = habituation_model.level(domain)
79
+ score = Helpers::Focus.score_signal(signal, habituation_level: hab_level, goal_relevance: goal_rel)
80
+ tier = Helpers::Focus.attention_tier(score)
81
+
82
+ Helpers::Focus.tag_signal(signal, attention_score: score, attention_tier: tier)
83
+ end
84
+ end
85
+
86
+ def categorize(scored_signals)
87
+ result = { spotlight: [], peripheral: [], background: [], dropped: 0 }
88
+
89
+ scored_signals.each do |signal|
90
+ case signal[:attention_tier]
91
+ when :spotlight then result[:spotlight] << signal
92
+ when :peripheral then result[:peripheral] << signal
93
+ when :background then result[:background] << signal
94
+ else result[:dropped] += 1
95
+ end
96
+ end
97
+
98
+ enforce_capacity(result)
99
+ end
100
+
101
+ def enforce_capacity(categorized)
102
+ capacity = Helpers::Constants::ATTENTIONAL_CAPACITY
103
+ spotlight = categorized[:spotlight].sort_by { |s| -(s[:attention_score] || 0) }
104
+
105
+ if spotlight.size > capacity
106
+ overflow = spotlight[capacity..]
107
+ categorized[:spotlight] = spotlight.first(capacity)
108
+ categorized[:peripheral].concat(overflow)
109
+ end
110
+
111
+ categorized
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Attention
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/attention/version'
4
+ require 'legion/extensions/attention/helpers/constants'
5
+ require 'legion/extensions/attention/helpers/focus'
6
+ require 'legion/extensions/attention/helpers/focus_manager'
7
+ require 'legion/extensions/attention/helpers/habituation'
8
+ require 'legion/extensions/attention/runners/attention'
9
+ require 'legion/extensions/attention/client'
10
+
11
+ module Legion
12
+ module Extensions
13
+ module Attention
14
+ extend Legion::Extensions::Core if Legion::Extensions.const_defined? :Core
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::Attention::Client do
4
+ subject(:client) { described_class.new }
5
+
6
+ it 'initializes with default focus manager and habituation model' do
7
+ status = client.attention_status
8
+ expect(status[:manual_focus]).to eq({})
9
+ expect(status[:capacity]).to eq(Legion::Extensions::Attention::Helpers::Constants::ATTENTIONAL_CAPACITY)
10
+ end
11
+
12
+ it 'includes the Attention runner' do
13
+ expect(client).to respond_to(:filter_signals)
14
+ expect(client).to respond_to(:attention_status)
15
+ expect(client).to respond_to(:focus_on)
16
+ expect(client).to respond_to(:release_focus)
17
+ expect(client).to respond_to(:habituation_stats)
18
+ end
19
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::Attention::Helpers::FocusManager do
4
+ subject(:manager) { described_class.new }
5
+
6
+ describe '#focus_on' do
7
+ it 'focuses on a domain' do
8
+ expect(manager.focus_on(:terraform)).to eq(:focused)
9
+ expect(manager.focused?(:terraform)).to be true
10
+ end
11
+
12
+ it 'converts string domains to symbols' do
13
+ manager.focus_on('vault')
14
+ expect(manager.focused?(:vault)).to be true
15
+ end
16
+
17
+ it 'returns :already_focused for duplicate focus' do
18
+ manager.focus_on(:terraform)
19
+ expect(manager.focus_on(:terraform)).to eq(:already_focused)
20
+ end
21
+
22
+ it 'returns :capacity_full when max is reached' do
23
+ Legion::Extensions::Attention::Helpers::Constants::MAX_MANUAL_FOCUS.times do |i|
24
+ manager.focus_on(:"domain_#{i}")
25
+ end
26
+ expect(manager.focus_on(:overflow)).to eq(:capacity_full)
27
+ end
28
+
29
+ it 'stores reason with the focus' do
30
+ manager.focus_on(:security, reason: 'incident response')
31
+ expect(manager.manual_focus[:security][:reason]).to eq('incident response')
32
+ end
33
+ end
34
+
35
+ describe '#release' do
36
+ it 'releases a focused domain' do
37
+ manager.focus_on(:terraform)
38
+ expect(manager.release(:terraform)).to eq(:released)
39
+ expect(manager.focused?(:terraform)).to be false
40
+ end
41
+
42
+ it 'returns :not_focused for unfocused domains' do
43
+ expect(manager.release(:nonexistent)).to eq(:not_focused)
44
+ end
45
+ end
46
+
47
+ describe '#focused?' do
48
+ it 'returns true for focused domains' do
49
+ manager.focus_on(:vault)
50
+ expect(manager.focused?(:vault)).to be true
51
+ end
52
+
53
+ it 'returns false for unfocused domains' do
54
+ expect(manager.focused?(:vault)).to be false
55
+ end
56
+ end
57
+
58
+ describe '#focus_boost' do
59
+ it 'returns FOCUS_BOOST for focused domains' do
60
+ manager.focus_on(:terraform)
61
+ expect(manager.focus_boost(:terraform)).to eq(
62
+ Legion::Extensions::Attention::Helpers::Constants::FOCUS_BOOST
63
+ )
64
+ end
65
+
66
+ it 'returns 0.0 for unfocused domains' do
67
+ expect(manager.focus_boost(:terraform)).to eq(0.0)
68
+ end
69
+ end
70
+
71
+ describe '#goal_relevance' do
72
+ it 'returns 0.0 with no focus or wonders' do
73
+ signal = { domain: :terraform, salience: 0.5 }
74
+ expect(manager.goal_relevance(signal)).to eq(0.0)
75
+ end
76
+
77
+ it 'returns boost for manually focused domain' do
78
+ manager.focus_on(:terraform)
79
+ signal = { domain: :terraform, salience: 0.5 }
80
+ expect(manager.goal_relevance(signal)).to be > 0.0
81
+ end
82
+
83
+ it 'returns relevance for domain matching an active wonder' do
84
+ signal = { domain: :terraform, salience: 0.5 }
85
+ wonders = [{ domain: :terraform, salience: 0.8 }]
86
+ relevance = manager.goal_relevance(signal, active_wonders: wonders)
87
+ expect(relevance).to be > 0.0
88
+ end
89
+
90
+ it 'returns 0.0 for non-matching wonder domains' do
91
+ signal = { domain: :terraform, salience: 0.5 }
92
+ wonders = [{ domain: :vault, salience: 0.8 }]
93
+ relevance = manager.goal_relevance(signal, active_wonders: wonders)
94
+ expect(relevance).to eq(0.0)
95
+ end
96
+
97
+ it 'caps at 1.0 even with focus and wonders combined' do
98
+ manager.focus_on(:terraform)
99
+ signal = { domain: :terraform, salience: 0.5 }
100
+ wonders = [{ domain: :terraform, salience: 1.0 }]
101
+ relevance = manager.goal_relevance(signal, active_wonders: wonders)
102
+ expect(relevance).to be <= 1.0
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::Attention::Helpers::Focus do
4
+ let(:focus) { described_class }
5
+
6
+ describe '.score_signal' do
7
+ it 'returns a float between 0 and 1' do
8
+ score = focus.score_signal({ salience: 0.5, novelty: 0.5 })
9
+ expect(score).to be_between(0.0, 1.0)
10
+ end
11
+
12
+ it 'returns higher scores for high salience signals' do
13
+ high = focus.score_signal({ salience: 0.9, novelty: 0.5 })
14
+ low = focus.score_signal({ salience: 0.1, novelty: 0.5 })
15
+ expect(high).to be > low
16
+ end
17
+
18
+ it 'reduces score with high habituation' do
19
+ fresh = focus.score_signal({ salience: 0.5 }, habituation_level: 0.0)
20
+ habituated = focus.score_signal({ salience: 0.5 }, habituation_level: 0.9)
21
+ expect(fresh).to be > habituated
22
+ end
23
+
24
+ it 'increases score with goal relevance' do
25
+ no_goal = focus.score_signal({ salience: 0.5 }, goal_relevance: 0.0)
26
+ with_goal = focus.score_signal({ salience: 0.5 }, goal_relevance: 1.0)
27
+ expect(with_goal).to be > no_goal
28
+ end
29
+
30
+ it 'clamps result to 1.0 max' do
31
+ score = focus.score_signal({ salience: 1.0, novelty: 1.0 },
32
+ habituation_level: 0.0, goal_relevance: 1.0)
33
+ expect(score).to eq(1.0)
34
+ end
35
+ end
36
+
37
+ describe '.extract_salience' do
38
+ it 'returns salience from signal hash' do
39
+ expect(focus.extract_salience({ salience: 0.7 })).to eq(0.7)
40
+ end
41
+
42
+ it 'falls back to attention_score' do
43
+ expect(focus.extract_salience({ attention_score: 0.4 })).to eq(0.4)
44
+ end
45
+
46
+ it 'defaults to 0.0 for non-hash' do
47
+ expect(focus.extract_salience('not a hash')).to eq(0.0)
48
+ end
49
+
50
+ it 'defaults to 0.0 for missing key' do
51
+ expect(focus.extract_salience({})).to eq(0.0)
52
+ end
53
+ end
54
+
55
+ describe '.extract_novelty' do
56
+ it 'returns novelty from signal hash' do
57
+ expect(focus.extract_novelty({ novelty: 0.8 })).to eq(0.8)
58
+ end
59
+
60
+ it 'defaults to 0.5' do
61
+ expect(focus.extract_novelty({})).to eq(0.5)
62
+ end
63
+
64
+ it 'defaults to 0.5 for non-hash' do
65
+ expect(focus.extract_novelty(42)).to eq(0.5)
66
+ end
67
+ end
68
+
69
+ describe '.extract_domain' do
70
+ it 'returns domain from signal hash' do
71
+ expect(focus.extract_domain({ domain: :terraform })).to eq(:terraform)
72
+ end
73
+
74
+ it 'falls back to source_type' do
75
+ expect(focus.extract_domain({ source_type: :consul })).to eq(:consul)
76
+ end
77
+
78
+ it 'defaults to :general' do
79
+ expect(focus.extract_domain({})).to eq(:general)
80
+ end
81
+
82
+ it 'defaults to :general for non-hash' do
83
+ expect(focus.extract_domain(nil)).to eq(:general)
84
+ end
85
+ end
86
+
87
+ describe '.tag_signal' do
88
+ it 'merges attention metadata into the signal' do
89
+ signal = { salience: 0.8, domain: :terraform }
90
+ tagged = focus.tag_signal(signal, attention_score: 0.75, attention_tier: :spotlight)
91
+ expect(tagged[:attention_score]).to eq(0.75)
92
+ expect(tagged[:attention_tier]).to eq(:spotlight)
93
+ expect(tagged[:salience]).to eq(0.8)
94
+ end
95
+
96
+ it 'returns non-hash signals unchanged' do
97
+ result = focus.tag_signal('string', attention_score: 0.5, attention_tier: :peripheral)
98
+ expect(result).to eq('string')
99
+ end
100
+ end
101
+
102
+ describe '.attention_tier' do
103
+ it 'returns :spotlight for >= 0.7' do
104
+ expect(focus.attention_tier(0.7)).to eq(:spotlight)
105
+ expect(focus.attention_tier(0.95)).to eq(:spotlight)
106
+ end
107
+
108
+ it 'returns :peripheral for >= 0.4' do
109
+ expect(focus.attention_tier(0.4)).to eq(:peripheral)
110
+ expect(focus.attention_tier(0.69)).to eq(:peripheral)
111
+ end
112
+
113
+ it 'returns :background for >= BACKGROUND_THRESHOLD' do
114
+ expect(focus.attention_tier(0.2)).to eq(:background)
115
+ expect(focus.attention_tier(0.39)).to eq(:background)
116
+ end
117
+
118
+ it 'returns :filtered for below BACKGROUND_THRESHOLD' do
119
+ expect(focus.attention_tier(0.19)).to eq(:filtered)
120
+ expect(focus.attention_tier(0.0)).to eq(:filtered)
121
+ end
122
+ end
123
+
124
+ describe '.deduplicate' do
125
+ it 'removes duplicate signals by identity' do
126
+ signals = [
127
+ { domain: :terraform, source_type: :event, value: 'deploy' },
128
+ { domain: :terraform, source_type: :event, value: 'deploy' },
129
+ { domain: :vault, source_type: :event, value: 'rotate' }
130
+ ]
131
+ result = focus.deduplicate(signals)
132
+ expect(result.size).to eq(2)
133
+ end
134
+
135
+ it 'preserves unique signals' do
136
+ signals = [
137
+ { domain: :a, value: 1 },
138
+ { domain: :b, value: 2 },
139
+ { domain: :c, value: 3 }
140
+ ]
141
+ expect(focus.deduplicate(signals).size).to eq(3)
142
+ end
143
+
144
+ it 'returns empty array for empty input' do
145
+ expect(focus.deduplicate([])).to eq([])
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::Attention::Helpers::Habituation do
4
+ subject(:model) { described_class.new }
5
+
6
+ describe '#record' do
7
+ it 'increases habituation level for a domain' do
8
+ model.record(:terraform)
9
+ expect(model.level(:terraform)).to be > 0.0
10
+ end
11
+
12
+ it 'accumulates with repeated recordings' do
13
+ 3.times { model.record(:terraform) }
14
+ expect(model.level(:terraform)).to be > model.level(:vault).to_f
15
+ end
16
+
17
+ it 'caps at HABITUATION_CEILING' do
18
+ ceiling = Legion::Extensions::Attention::Helpers::Constants::HABITUATION_CEILING
19
+ 20.times { model.record(:terraform) }
20
+ expect(model.level(:terraform)).to be <= ceiling
21
+ end
22
+
23
+ it 'converts string domains to symbols' do
24
+ model.record('terraform')
25
+ expect(model.level(:terraform)).to be > 0.0
26
+ end
27
+ end
28
+
29
+ describe '#level' do
30
+ it 'returns 0.0 for unknown domains' do
31
+ expect(model.level(:unknown)).to eq(0.0)
32
+ end
33
+ end
34
+
35
+ describe '#apply_novelty_reset' do
36
+ it 'does nothing when novelty <= 0.5' do
37
+ model.record(:terraform)
38
+ level_before = model.level(:terraform)
39
+ model.apply_novelty_reset(:terraform, novelty: 0.3)
40
+ expect(model.level(:terraform)).to eq(level_before)
41
+ end
42
+
43
+ it 'reduces habituation when novelty > 0.5' do
44
+ 5.times { model.record(:terraform) }
45
+ level_before = model.level(:terraform)
46
+ model.apply_novelty_reset(:terraform, novelty: 0.9)
47
+ expect(model.level(:terraform)).to be < level_before
48
+ end
49
+
50
+ it 'never reduces below 0.0' do
51
+ model.record(:terraform)
52
+ model.apply_novelty_reset(:terraform, novelty: 1.0)
53
+ expect(model.level(:terraform)).to be >= 0.0
54
+ end
55
+ end
56
+
57
+ describe '#decay_all' do
58
+ it 'reduces habituation for all recorded domains' do
59
+ 3.times { model.record(:terraform) }
60
+ 3.times { model.record(:vault) }
61
+ tf_before = model.level(:terraform)
62
+ v_before = model.level(:vault)
63
+
64
+ model.decay_all
65
+ expect(model.level(:terraform)).to be < tf_before
66
+ expect(model.level(:vault)).to be < v_before
67
+ end
68
+
69
+ it 'removes domains that decay to zero' do
70
+ model.record(:terraform)
71
+ model.apply_novelty_reset(:terraform, novelty: 1.0)
72
+ model.decay_all
73
+ expect(model.stats).not_to have_key(:terraform)
74
+ end
75
+ end
76
+
77
+ describe '#stats' do
78
+ it 'returns rounded exposure levels' do
79
+ model.record(:terraform)
80
+ stats = model.stats
81
+ expect(stats[:terraform]).to be_a(Float)
82
+ expect(stats[:terraform].to_s.split('.').last.length).to be <= 3
83
+ end
84
+ end
85
+
86
+ describe '#habituated_domains' do
87
+ it 'returns domains above threshold' do
88
+ 10.times { model.record(:terraform) }
89
+ expect(model.habituated_domains).to include(:terraform)
90
+ end
91
+
92
+ it 'excludes domains below threshold' do
93
+ model.record(:terraform)
94
+ expect(model.habituated_domains(threshold: 0.5)).not_to include(:terraform)
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::Attention::Runners::Attention do
4
+ let(:client) { Legion::Extensions::Attention::Client.new }
5
+
6
+ describe '#filter_signals' do
7
+ it 'returns empty result for empty input' do
8
+ result = client.filter_signals(signals: [])
9
+ expect(result[:filtered]).to eq([])
10
+ expect(result[:spotlight]).to eq(0)
11
+ expect(result[:peripheral]).to eq(0)
12
+ expect(result[:background]).to eq(0)
13
+ expect(result[:dropped]).to eq(0)
14
+ end
15
+
16
+ it 'categorizes high-salience signals as spotlight with goal boost' do
17
+ signals = [{ salience: 0.9, domain: :terraform, novelty: 0.8 }]
18
+ wonders = [{ domain: :terraform, salience: 0.9 }]
19
+ result = client.filter_signals(signals: signals, active_wonders: wonders)
20
+ expect(result[:spotlight]).to be >= 1
21
+ end
22
+
23
+ it 'categorizes high-salience signals into filtered output' do
24
+ signals = [{ salience: 0.9, domain: :terraform, novelty: 0.8 }]
25
+ result = client.filter_signals(signals: signals)
26
+ expect(result[:filtered].size).to be >= 1
27
+ end
28
+
29
+ it 'drops low-salience signals' do
30
+ signals = [{ salience: 0.01, domain: :heartbeat, novelty: 0.0 }]
31
+ result = client.filter_signals(signals: signals)
32
+ expect(result[:dropped]).to be >= 0
33
+ expect(result[:filtered].size).to be <= 1
34
+ end
35
+
36
+ it 'boosts signals matching active wonders' do
37
+ signals = [{ salience: 0.3, domain: :terraform, novelty: 0.3 }]
38
+ wonders = [{ domain: :terraform, salience: 0.9 }]
39
+
40
+ without = client.filter_signals(signals: signals)
41
+ # New client to avoid habituation carryover
42
+ fresh = Legion::Extensions::Attention::Client.new
43
+ with_wonders = fresh.filter_signals(signals: signals, active_wonders: wonders)
44
+
45
+ # With wonder boost, the signal should score higher
46
+ with_total = with_wonders[:spotlight] + with_wonders[:peripheral]
47
+ without_total = without[:spotlight] + without[:peripheral]
48
+ expect(with_total).to be >= without_total
49
+ end
50
+
51
+ it 'deduplicates identical signals' do
52
+ signals = [
53
+ { salience: 0.8, domain: :terraform, source_type: :event, value: 'deploy' },
54
+ { salience: 0.8, domain: :terraform, source_type: :event, value: 'deploy' }
55
+ ]
56
+ result = client.filter_signals(signals: signals)
57
+ expect(result[:spotlight] + result[:peripheral] + result[:background]).to be <= 1
58
+ end
59
+
60
+ it 'enforces attentional capacity' do
61
+ signals = 10.times.map { |i| { salience: 0.9, domain: :"domain_#{i}", novelty: 0.9 } }
62
+ result = client.filter_signals(signals: signals)
63
+ expect(result[:spotlight]).to be <= Legion::Extensions::Attention::Helpers::Constants::ATTENTIONAL_CAPACITY
64
+ end
65
+
66
+ it 'habituates repeated domain signals' do
67
+ first_client = Legion::Extensions::Attention::Client.new
68
+ signals = [{ salience: 0.6, domain: :terraform, novelty: 0.3 }]
69
+
70
+ first_result = first_client.filter_signals(signals: signals)
71
+ # Record same domain multiple times to build habituation
72
+ 10.times { first_client.filter_signals(signals: signals) }
73
+ later_result = first_client.filter_signals(signals: signals)
74
+
75
+ first_scores = first_result[:filtered].map { |s| s[:attention_score] || 0 }
76
+ later_scores = later_result[:filtered].map { |s| s[:attention_score] || 0 }
77
+
78
+ # Later scores should be lower due to habituation (if signal still passes)
79
+ expect(later_scores.max).to be <= first_scores.max if first_scores.any? && later_scores.any?
80
+ end
81
+ end
82
+
83
+ describe '#attention_status' do
84
+ it 'returns status hash' do
85
+ status = client.attention_status
86
+ expect(status[:manual_focus]).to eq({})
87
+ expect(status[:habituated_domains]).to eq([])
88
+ expect(status[:capacity]).to eq(Legion::Extensions::Attention::Helpers::Constants::ATTENTIONAL_CAPACITY)
89
+ expect(status[:focus_count]).to eq(0)
90
+ end
91
+ end
92
+
93
+ describe '#focus_on' do
94
+ it 'focuses on a domain' do
95
+ result = client.focus_on(domain: :terraform, reason: 'deployment')
96
+ expect(result[:status]).to eq(:focused)
97
+ expect(result[:domain]).to eq(:terraform)
98
+ end
99
+
100
+ it 'updates attention status' do
101
+ client.focus_on(domain: :security, reason: 'incident')
102
+ status = client.attention_status
103
+ expect(status[:focus_count]).to eq(1)
104
+ end
105
+ end
106
+
107
+ describe '#release_focus' do
108
+ it 'releases a focused domain' do
109
+ client.focus_on(domain: :terraform)
110
+ result = client.release_focus(domain: :terraform)
111
+ expect(result[:status]).to eq(:released)
112
+ end
113
+
114
+ it 'returns :not_focused for unknown domain' do
115
+ result = client.release_focus(domain: :unknown)
116
+ expect(result[:status]).to eq(:not_focused)
117
+ end
118
+ end
119
+
120
+ describe '#habituation_stats' do
121
+ it 'returns stats hash' do
122
+ stats = client.habituation_stats
123
+ expect(stats[:domains]).to be_a(Hash)
124
+ expect(stats[:habituated]).to be_an(Array)
125
+ end
126
+
127
+ it 'reflects domain exposure after filtering' do
128
+ signals = [{ salience: 0.8, domain: :terraform, novelty: 0.3 }]
129
+ 5.times { client.filter_signals(signals: signals) }
130
+ stats = client.habituation_stats
131
+ expect(stats[:domains]).to have_key(:terraform)
132
+ end
133
+ end
134
+ 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/attention'
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,78 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: lex-attention
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: Selective attention filter for brain-modeled agentic AI
27
+ email:
28
+ - matthewdiverson@gmail.com
29
+ executables: []
30
+ extensions: []
31
+ extra_rdoc_files: []
32
+ files:
33
+ - Gemfile
34
+ - LICENSE
35
+ - README.md
36
+ - lex-attention.gemspec
37
+ - lib/legion/extensions/attention.rb
38
+ - lib/legion/extensions/attention/client.rb
39
+ - lib/legion/extensions/attention/helpers/constants.rb
40
+ - lib/legion/extensions/attention/helpers/focus.rb
41
+ - lib/legion/extensions/attention/helpers/focus_manager.rb
42
+ - lib/legion/extensions/attention/helpers/habituation.rb
43
+ - lib/legion/extensions/attention/runners/attention.rb
44
+ - lib/legion/extensions/attention/version.rb
45
+ - spec/legion/extensions/attention/client_spec.rb
46
+ - spec/legion/extensions/attention/helpers/focus_manager_spec.rb
47
+ - spec/legion/extensions/attention/helpers/focus_spec.rb
48
+ - spec/legion/extensions/attention/helpers/habituation_spec.rb
49
+ - spec/legion/extensions/attention/runners/attention_spec.rb
50
+ - spec/spec_helper.rb
51
+ homepage: https://github.com/LegionIO/lex-attention
52
+ licenses:
53
+ - MIT
54
+ metadata:
55
+ homepage_uri: https://github.com/LegionIO/lex-attention
56
+ source_code_uri: https://github.com/LegionIO/lex-attention
57
+ documentation_uri: https://github.com/LegionIO/lex-attention
58
+ changelog_uri: https://github.com/LegionIO/lex-attention
59
+ bug_tracker_uri: https://github.com/LegionIO/lex-attention/issues
60
+ rubygems_mfa_required: 'true'
61
+ rdoc_options: []
62
+ require_paths:
63
+ - lib
64
+ required_ruby_version: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '3.4'
69
+ required_rubygems_version: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - ">="
72
+ - !ruby/object:Gem::Version
73
+ version: '0'
74
+ requirements: []
75
+ rubygems_version: 3.6.9
76
+ specification_version: 4
77
+ summary: LEX Attention
78
+ test_files: []