lex-affordance 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: c87927c90cc5dfc5bd73e728ad8952b88ac73eaf98d287f1c50f250694765e5b
4
+ data.tar.gz: 334f20991fedba8202b3d846780d50b69874bbb452af0d529763b080a1845867
5
+ SHA512:
6
+ metadata.gz: 03e2342c50eab44e9773bbcc9d7e7d8f12418807d06e5dc4a9665ccbb6a093239b851e2a2e44bc881260a73bc80968c324e92730ae25dc262e54d415142d65b2
7
+ data.tar.gz: e677b9ed5bc2cc6c2d645c07c9514cc41969ad723ffda66bf8276daa1b73c9d9972d2c3b45fbba7f0db66bbd911ae532ad472f9e623f0e218431fe0ce33e1f39
data/README.md ADDED
@@ -0,0 +1,65 @@
1
+ # lex-affordance
2
+
3
+ Gibson's ecological affordance perception for LegionIO — detecting action possibilities in the environment.
4
+
5
+ ## What It Does
6
+
7
+ Models J.J. Gibson's ecological theory of affordances: the agent perceives its environment in terms of what actions are possible, blocked, risky, or threatening given its current capabilities. The extension maintains an affordance field that tracks opportunities, threats, and action feasibility. Affordances decay over time as the environment changes.
8
+
9
+ ## Core Concept: The Affordance Field
10
+
11
+ An affordance describes an action-environment relationship from the agent's perspective:
12
+
13
+ ```ruby
14
+ # Register what the agent can do
15
+ client.register_capability(name: :make_api_call, domain: :http, level: 0.9)
16
+
17
+ # Detect what the environment affords
18
+ client.detect_affordance(
19
+ action: :deploy_service,
20
+ domain: :infrastructure,
21
+ affordance_type: :action_possible,
22
+ requires: [:make_api_call],
23
+ relevance: 0.8
24
+ )
25
+
26
+ # Check if an action is feasible
27
+ result = client.evaluate_action(action: :deploy_service, domain: :infrastructure)
28
+ # => { feasible: true, reason: :capable, risks: [], relevance: 0.8 }
29
+ ```
30
+
31
+ ## Usage
32
+
33
+ ```ruby
34
+ client = Legion::Extensions::Affordance::Client.new
35
+
36
+ # Set environmental conditions
37
+ client.set_environment(property: :network_available, value: true, domain: :infrastructure)
38
+
39
+ # List what's immediately actionable (relevance >= 0.5)
40
+ client.actionable_affordances
41
+ # => { affordances: [...], count: 2 }
42
+
43
+ # Check for threats
44
+ client.current_threats
45
+ # => { threats: [...], count: 1 }
46
+
47
+ # Maintenance (also runs automatically every 30s via Scan actor)
48
+ client.update_affordances
49
+ ```
50
+
51
+ ## Integration
52
+
53
+ The `Scan` actor decays affordances every 30 seconds automatically. Wire into lex-tick's `action_selection` phase to filter candidate actions by feasibility before execution.
54
+
55
+ ## Development
56
+
57
+ ```bash
58
+ bundle install
59
+ bundle exec rspec
60
+ bundle exec rubocop
61
+ ```
62
+
63
+ ## License
64
+
65
+ MIT
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Affordance
6
+ module Actors
7
+ class Scan < Legion::Extensions::Actors::Every
8
+ INTERVAL = 30
9
+
10
+ def run
11
+ Runners::Affordance.instance_method(:update_affordances).bind_call(runner_instance)
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/affordance/helpers/constants'
4
+ require 'legion/extensions/affordance/helpers/affordance_item'
5
+ require 'legion/extensions/affordance/helpers/affordance_field'
6
+ require 'legion/extensions/affordance/runners/affordance'
7
+
8
+ module Legion
9
+ module Extensions
10
+ module Affordance
11
+ class Client
12
+ include Runners::Affordance
13
+
14
+ def initialize(field: nil, **)
15
+ @field = field || Helpers::AffordanceField.new
16
+ end
17
+
18
+ private
19
+
20
+ attr_reader :field
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Affordance
6
+ module Helpers
7
+ class AffordanceField
8
+ include Constants
9
+
10
+ attr_reader :affordances, :capabilities, :environment
11
+
12
+ def initialize
13
+ @affordances = {}
14
+ @capabilities = {}
15
+ @environment = {}
16
+ @counter = 0
17
+ @history = []
18
+ end
19
+
20
+ def register_capability(name:, domain: :general, level: 1.0)
21
+ return nil if @capabilities.size >= MAX_CAPABILITIES
22
+
23
+ @capabilities[name] = { domain: domain, level: level.to_f.clamp(0.0, 1.0) }
24
+ end
25
+
26
+ def set_environment(property:, value:, domain: :general)
27
+ return nil if @environment.size >= MAX_ENVIRONMENT_PROPS && !@environment.key?(property)
28
+
29
+ @environment[property] = { value: value, domain: domain, updated_at: Time.now.utc }
30
+ end
31
+
32
+ def detect_affordance(action:, domain:, affordance_type:, requires: [], relevance: DEFAULT_RELEVANCE)
33
+ return nil unless AFFORDANCE_TYPES.include?(affordance_type)
34
+ return nil if @affordances.size >= MAX_AFFORDANCES
35
+
36
+ @counter += 1
37
+ aff_id = :"aff_#{@counter}"
38
+ aff = AffordanceItem.new(
39
+ id: aff_id, action: action, domain: domain,
40
+ affordance_type: affordance_type, requires: requires, relevance: relevance
41
+ )
42
+ @affordances[aff_id] = aff
43
+ record_detection(aff)
44
+ aff
45
+ end
46
+
47
+ def evaluate_action(action:, domain:)
48
+ matching = @affordances.values.select { |a| a.action == action && a.domain == domain }
49
+ return { feasible: false, reason: :no_affordance } if matching.empty?
50
+
51
+ check_blockers(matching) || build_evaluation(matching)
52
+ end
53
+
54
+ def actionable_affordances
55
+ @affordances.values.select(&:actionable?).sort_by { |a| -a.relevance }.map(&:to_h)
56
+ end
57
+
58
+ def threats
59
+ @affordances.values.select(&:threatening?).map(&:to_h)
60
+ end
61
+
62
+ def affordances_in(domain:)
63
+ @affordances.values.select { |a| a.domain == domain }.map(&:to_h)
64
+ end
65
+
66
+ def decay_all
67
+ @affordances.each_value(&:decay)
68
+ @affordances.reject! { |_, a| a.faded? }
69
+ end
70
+
71
+ def to_h
72
+ {
73
+ affordance_count: @affordances.size,
74
+ capability_count: @capabilities.size,
75
+ environment_props: @environment.size,
76
+ actionable_count: @affordances.values.count(&:actionable?),
77
+ blocked_count: @affordances.values.count(&:blocked?),
78
+ threat_count: @affordances.values.count(&:threatening?),
79
+ history_size: @history.size
80
+ }
81
+ end
82
+
83
+ private
84
+
85
+ def check_blockers(matching)
86
+ blockers = matching.select(&:blocked?)
87
+ return nil if blockers.empty?
88
+
89
+ { feasible: false, reason: :blocked, blockers: blockers.map(&:to_h) }
90
+ end
91
+
92
+ def build_evaluation(matching)
93
+ capabilities_met = check_requirements(matching)
94
+ {
95
+ feasible: capabilities_met,
96
+ reason: capabilities_met ? :capable : :missing_capabilities,
97
+ risks: matching.select(&:risky?).map(&:to_h),
98
+ relevance: matching.map(&:relevance).max,
99
+ affordance_count: matching.size
100
+ }
101
+ end
102
+
103
+ def check_requirements(affordances)
104
+ required = affordances.flat_map(&:requires).uniq
105
+ return true if required.empty?
106
+
107
+ required.all? { |r| @capabilities.key?(r) }
108
+ end
109
+
110
+ def record_detection(affordance)
111
+ @history << { id: affordance.id, action: affordance.action, type: affordance.affordance_type,
112
+ at: Time.now.utc }
113
+ @history.shift while @history.size > MAX_HISTORY
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Affordance
6
+ module Helpers
7
+ class AffordanceItem
8
+ include Constants
9
+
10
+ attr_reader :id, :action, :domain, :affordance_type, :requires, :detected_at
11
+ attr_accessor :relevance
12
+
13
+ def initialize(id:, action:, domain:, affordance_type:, requires: [], relevance: DEFAULT_RELEVANCE)
14
+ @id = id
15
+ @action = action
16
+ @domain = domain
17
+ @affordance_type = affordance_type
18
+ @requires = Array(requires)
19
+ @relevance = relevance.to_f.clamp(0.0, 1.0)
20
+ @detected_at = Time.now.utc
21
+ end
22
+
23
+ def actionable?
24
+ %i[action_possible resource_available opportunity].include?(@affordance_type) &&
25
+ @relevance >= ACTIONABLE_THRESHOLD
26
+ end
27
+
28
+ def blocked?
29
+ @affordance_type == :action_blocked
30
+ end
31
+
32
+ def risky?
33
+ @affordance_type == :action_risky
34
+ end
35
+
36
+ def threatening?
37
+ @affordance_type == :threat
38
+ end
39
+
40
+ def decay
41
+ @relevance = [@relevance - RELEVANCE_DECAY, 0.0].max
42
+ end
43
+
44
+ def faded?
45
+ @relevance <= RELEVANCE_FLOOR
46
+ end
47
+
48
+ def relevance_label
49
+ RELEVANCE_LABELS.each { |range, lbl| return lbl if range.cover?(@relevance) }
50
+ :negligible
51
+ end
52
+
53
+ def to_h
54
+ {
55
+ id: @id,
56
+ action: @action,
57
+ domain: @domain,
58
+ affordance_type: @affordance_type,
59
+ requires: @requires,
60
+ relevance: @relevance.round(4),
61
+ relevance_label: relevance_label,
62
+ actionable: actionable?,
63
+ blocked: blocked?,
64
+ risky: risky?
65
+ }
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Affordance
6
+ module Helpers
7
+ module Constants
8
+ MAX_AFFORDANCES = 200
9
+ MAX_CAPABILITIES = 50
10
+ MAX_ENVIRONMENT_PROPS = 100
11
+ MAX_HISTORY = 200
12
+
13
+ RELEVANCE_FLOOR = 0.05
14
+ RELEVANCE_DECAY = 0.01
15
+ DEFAULT_RELEVANCE = 0.5
16
+ URGENCY_BOOST = 0.2
17
+
18
+ CAPABILITY_MATCH_THRESHOLD = 0.3
19
+ ACTIONABLE_THRESHOLD = 0.5
20
+
21
+ AFFORDANCE_TYPES = %i[
22
+ action_possible action_blocked action_risky
23
+ resource_available resource_depleted
24
+ opportunity threat neutral
25
+ ].freeze
26
+
27
+ RELEVANCE_LABELS = {
28
+ (0.8..) => :critical,
29
+ (0.6...0.8) => :important,
30
+ (0.4...0.6) => :moderate,
31
+ (0.2...0.4) => :minor,
32
+ (..0.2) => :negligible
33
+ }.freeze
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Affordance
6
+ module Runners
7
+ module Affordance
8
+ include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers) &&
9
+ Legion::Extensions::Helpers.const_defined?(:Lex)
10
+
11
+ def register_capability(name:, domain: :general, level: 1.0, **)
12
+ Legion::Logging.debug "[affordance] capability: #{name} domain=#{domain}"
13
+ cap = field.register_capability(name: name, domain: domain, level: level)
14
+ if cap
15
+ { success: true, capability: name, capabilities: field.capabilities.size }
16
+ else
17
+ { success: false, reason: :limit_reached }
18
+ end
19
+ end
20
+
21
+ def set_environment(property:, value:, domain: :general, **)
22
+ Legion::Logging.debug "[affordance] env: #{property}=#{value}"
23
+ result = field.set_environment(property: property, value: value, domain: domain)
24
+ if result
25
+ { success: true, property: property }
26
+ else
27
+ { success: false, reason: :limit_reached }
28
+ end
29
+ end
30
+
31
+ def detect_affordance(action:, domain:, affordance_type:, requires: [], relevance: nil, **)
32
+ rel = relevance || Helpers::Constants::DEFAULT_RELEVANCE
33
+ Legion::Logging.debug "[affordance] detect: #{action} type=#{affordance_type}"
34
+ aff = field.detect_affordance(
35
+ action: action, domain: domain, affordance_type: affordance_type.to_sym,
36
+ requires: requires, relevance: rel
37
+ )
38
+ if aff
39
+ { success: true, affordance: aff.to_h }
40
+ else
41
+ { success: false, reason: :invalid_or_full }
42
+ end
43
+ end
44
+
45
+ def evaluate_action(action:, domain:, **)
46
+ result = field.evaluate_action(action: action, domain: domain)
47
+ Legion::Logging.debug "[affordance] evaluate: #{action} feasible=#{result[:feasible]}"
48
+ { success: true, **result }
49
+ end
50
+
51
+ def actionable_affordances(**)
52
+ items = field.actionable_affordances
53
+ { success: true, affordances: items, count: items.size }
54
+ end
55
+
56
+ def current_threats(**)
57
+ items = field.threats
58
+ { success: true, threats: items, count: items.size }
59
+ end
60
+
61
+ def affordances_in_domain(domain:, **)
62
+ items = field.affordances_in(domain: domain)
63
+ { success: true, affordances: items, count: items.size }
64
+ end
65
+
66
+ def update_affordances(**)
67
+ Legion::Logging.debug '[affordance] tick'
68
+ field.decay_all
69
+ { success: true, remaining: field.affordances.size }
70
+ end
71
+
72
+ def affordance_stats(**)
73
+ Legion::Logging.debug '[affordance] stats'
74
+ { success: true, stats: field.to_h }
75
+ end
76
+
77
+ private
78
+
79
+ def field
80
+ @field ||= Helpers::AffordanceField.new
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Affordance
6
+ VERSION = '0.1.0'
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/affordance/version'
4
+ require 'legion/extensions/affordance/helpers/constants'
5
+ require 'legion/extensions/affordance/helpers/affordance_item'
6
+ require 'legion/extensions/affordance/helpers/affordance_field'
7
+ require 'legion/extensions/affordance/runners/affordance'
8
+ require 'legion/extensions/affordance/client'
9
+
10
+ module Legion
11
+ module Extensions
12
+ module Affordance
13
+ extend Legion::Extensions::Core if Legion::Extensions.const_defined?(:Core)
14
+ end
15
+ end
16
+ end
metadata ADDED
@@ -0,0 +1,70 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: lex-affordance
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: Gibson's ecological affordance perception for LegionIO — detecting action
27
+ possibilities in the environment
28
+ email:
29
+ - matthewdiverson@gmail.com
30
+ executables: []
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - README.md
35
+ - lib/legion/extensions/affordance.rb
36
+ - lib/legion/extensions/affordance/actors/scan.rb
37
+ - lib/legion/extensions/affordance/client.rb
38
+ - lib/legion/extensions/affordance/helpers/affordance_field.rb
39
+ - lib/legion/extensions/affordance/helpers/affordance_item.rb
40
+ - lib/legion/extensions/affordance/helpers/constants.rb
41
+ - lib/legion/extensions/affordance/runners/affordance.rb
42
+ - lib/legion/extensions/affordance/version.rb
43
+ homepage: https://github.com/LegionIO/lex-affordance
44
+ licenses:
45
+ - MIT
46
+ metadata:
47
+ homepage_uri: https://github.com/LegionIO/lex-affordance
48
+ source_code_uri: https://github.com/LegionIO/lex-affordance
49
+ documentation_uri: https://github.com/LegionIO/lex-affordance/blob/master/README.md
50
+ changelog_uri: https://github.com/LegionIO/lex-affordance/blob/master/CHANGELOG.md
51
+ bug_tracker_uri: https://github.com/LegionIO/lex-affordance/issues
52
+ rubygems_mfa_required: 'true'
53
+ rdoc_options: []
54
+ require_paths:
55
+ - lib
56
+ required_ruby_version: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '3.4'
61
+ required_rubygems_version: !ruby/object:Gem::Requirement
62
+ requirements:
63
+ - - ">="
64
+ - !ruby/object:Gem::Version
65
+ version: '0'
66
+ requirements: []
67
+ rubygems_version: 3.6.9
68
+ specification_version: 4
69
+ summary: LegionIO affordance perception extension
70
+ test_files: []