lex-causal-attribution 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: 55c6a453c0a0102f9922c5fcd31988701ff6d3a683ecaa84512165532caeb10a
4
+ data.tar.gz: ed1593cdba918561fcbb2ac9c93a994d42386afe628c89f5079b3ebd790fef67
5
+ SHA512:
6
+ metadata.gz: c6bded9b8ef506d179343b15c256978246aa371c900f0e366030355bdd73c8d9462f5712114c49c96aab62c2c92342f03981b8f935bb86ec729cb8dfe07a4734
7
+ data.tar.gz: e01f123090aab3668fe2d80090d0417cc1a3889b10acde2e0802bc79aea2b295d38d75fa5b4a49a7e7febbfd41c0837a1ab7945a7aa4695a1ab5afbb2a36023b
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,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'lib/legion/extensions/causal_attribution/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'lex-causal-attribution'
7
+ spec.version = Legion::Extensions::CausalAttribution::VERSION
8
+ spec.authors = ['Esity']
9
+ spec.email = ['matthewdiverson@gmail.com']
10
+
11
+ spec.summary = 'LEX Causal Attribution'
12
+ spec.description = "Weiner's attribution theory for brain-modeled agentic AI — locus, stability, controllability"
13
+ spec.homepage = 'https://github.com/LegionIO/lex-causal-attribution'
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-causal-attribution'
19
+ spec.metadata['documentation_uri'] = 'https://github.com/LegionIO/lex-causal-attribution'
20
+ spec.metadata['changelog_uri'] = 'https://github.com/LegionIO/lex-causal-attribution'
21
+ spec.metadata['bug_tracker_uri'] = 'https://github.com/LegionIO/lex-causal-attribution/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-causal-attribution.gemspec Gemfile]
26
+ end
27
+ spec.require_paths = ['lib']
28
+ spec.add_development_dependency 'legion-gaia'
29
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/causal_attribution/helpers/attribution'
4
+ require 'legion/extensions/causal_attribution/helpers/attribution_engine'
5
+ require 'legion/extensions/causal_attribution/runners/causal_attribution'
6
+
7
+ module Legion
8
+ module Extensions
9
+ module CausalAttribution
10
+ class Client
11
+ include Runners::CausalAttribution
12
+
13
+ def initialize(**)
14
+ @engine = Helpers::AttributionEngine.new
15
+ end
16
+
17
+ private
18
+
19
+ attr_reader :engine
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module Legion
6
+ module Extensions
7
+ module CausalAttribution
8
+ module Helpers
9
+ class Attribution
10
+ MAX_ATTRIBUTIONS = 200
11
+ MAX_HISTORY = 300
12
+
13
+ LOCUS_VALUES = %i[internal external].freeze
14
+ STABILITY_VALUES = %i[stable unstable].freeze
15
+ CONTROLLABILITY_VALUES = %i[controllable uncontrollable].freeze
16
+
17
+ DEFAULT_CONFIDENCE = 0.5
18
+ CONFIDENCE_FLOOR = 0.0
19
+ CONFIDENCE_CEILING = 1.0
20
+ DECAY_RATE = 0.02
21
+
22
+ ATTRIBUTION_EMOTIONS = {
23
+ %i[internal stable controllable] => :guilt,
24
+ %i[internal stable uncontrollable] => :shame,
25
+ %i[internal unstable controllable] => :regret,
26
+ %i[internal unstable uncontrollable] => :surprise,
27
+ %i[external stable controllable] => :anger,
28
+ %i[external stable uncontrollable] => :helplessness,
29
+ %i[external unstable controllable] => :frustration,
30
+ %i[external unstable uncontrollable] => :relief
31
+ }.freeze
32
+
33
+ attr_reader :id, :event, :outcome, :domain, :locus, :stability, :controllability,
34
+ :confidence, :emotional_response, :created_at
35
+
36
+ def initialize(event:, outcome:, domain:, locus:, stability:, controllability:,
37
+ confidence: DEFAULT_CONFIDENCE)
38
+ @id = SecureRandom.uuid
39
+ @event = event
40
+ @outcome = outcome
41
+ @domain = domain
42
+ @locus = locus
43
+ @stability = stability
44
+ @controllability = controllability
45
+ @confidence = confidence.clamp(CONFIDENCE_FLOOR, CONFIDENCE_CEILING)
46
+ @emotional_response = ATTRIBUTION_EMOTIONS[pattern]
47
+ @created_at = Time.now.utc
48
+ end
49
+
50
+ def pattern
51
+ [locus, stability, controllability]
52
+ end
53
+
54
+ def internal?
55
+ locus == :internal
56
+ end
57
+
58
+ def external?
59
+ locus == :external
60
+ end
61
+
62
+ def stable?
63
+ stability == :stable
64
+ end
65
+
66
+ def controllable?
67
+ controllability == :controllable
68
+ end
69
+
70
+ def to_h
71
+ {
72
+ id: id,
73
+ event: event,
74
+ outcome: outcome,
75
+ domain: domain,
76
+ locus: locus,
77
+ stability: stability,
78
+ controllability: controllability,
79
+ confidence: confidence,
80
+ emotional_response: emotional_response,
81
+ created_at: created_at
82
+ }
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module CausalAttribution
6
+ module Helpers
7
+ class AttributionEngine
8
+ def initialize
9
+ @attributions = {}
10
+ @history = []
11
+ end
12
+
13
+ def create_attribution(event:, outcome:, domain:, locus:, stability:, controllability:,
14
+ confidence: Attribution::DEFAULT_CONFIDENCE)
15
+ trim_if_needed
16
+ attribution = Attribution.new(
17
+ event: event,
18
+ outcome: outcome,
19
+ domain: domain,
20
+ locus: locus,
21
+ stability: stability,
22
+ controllability: controllability,
23
+ confidence: confidence
24
+ )
25
+ @attributions[attribution.id] = attribution
26
+ attribution
27
+ end
28
+
29
+ def reattribute(attribution_id:, locus: nil, stability: nil, controllability: nil)
30
+ attr = @attributions[attribution_id]
31
+ return { found: false, attribution_id: attribution_id } unless attr
32
+
33
+ new_locus = locus || attr.locus
34
+ new_stability = stability || attr.stability
35
+ new_controllability = controllability || attr.controllability
36
+
37
+ updated = Attribution.new(
38
+ event: attr.event,
39
+ outcome: attr.outcome,
40
+ domain: attr.domain,
41
+ locus: new_locus,
42
+ stability: new_stability,
43
+ controllability: new_controllability,
44
+ confidence: attr.confidence
45
+ )
46
+ @history << attr.to_h if @history.size < Attribution::MAX_HISTORY
47
+ @attributions[attribution_id] = updated
48
+ updated
49
+ end
50
+
51
+ def by_pattern(locus: nil, stability: nil, controllability: nil)
52
+ @attributions.values.select do |a|
53
+ (locus.nil? || a.locus == locus) &&
54
+ (stability.nil? || a.stability == stability) &&
55
+ (controllability.nil? || a.controllability == controllability)
56
+ end
57
+ end
58
+
59
+ def by_domain(domain:)
60
+ @attributions.values.select { |a| a.domain == domain }
61
+ end
62
+
63
+ def by_outcome(outcome:)
64
+ @attributions.values.select { |a| a.outcome == outcome }
65
+ end
66
+
67
+ def attribution_bias
68
+ return { bias: :none, detail: 'no attributions' } if @attributions.empty?
69
+
70
+ all = @attributions.values
71
+ total = all.size.to_f
72
+ locus = bias_ratio(all, :locus, :external, total)
73
+ stab = bias_ratio(all, :stability, :stable, total)
74
+ control = bias_ratio(all, :controllability, :uncontrollable, total)
75
+
76
+ failures = all.select { |a| a.outcome == :failure }
77
+ external_failure_ratio = failures.empty? ? 0.0 : failures.count(&:external?).to_f / failures.size
78
+
79
+ {
80
+ external_locus_ratio: locus.round(3),
81
+ stable_ratio: stab.round(3),
82
+ uncontrollable_ratio: control.round(3),
83
+ external_failure_ratio: external_failure_ratio.round(3),
84
+ self_serving_bias_detected: external_failure_ratio > 0.6,
85
+ total_attributions: @attributions.size
86
+ }
87
+ end
88
+
89
+ def emotional_profile
90
+ counts = Hash.new(0)
91
+ @attributions.each_value { |a| counts[a.emotional_response] += 1 if a.emotional_response }
92
+ total = counts.values.sum.to_f
93
+ profile = counts.transform_values { |v| (v / total).round(3) }
94
+ dominant = counts.max_by { |_, v| v }&.first
95
+ { distribution: profile, dominant: dominant, total: counts.values.sum }
96
+ end
97
+
98
+ def most_common_pattern
99
+ return { pattern: nil, count: 0 } if @attributions.empty?
100
+
101
+ grouped = @attributions.values.group_by(&:pattern)
102
+ best = grouped.max_by { |_, attrs| attrs.size }
103
+ { pattern: best[0], count: best[1].size }
104
+ end
105
+
106
+ def decay_all
107
+ decayed = 0
108
+ @attributions.each_value do |a|
109
+ new_conf = (a.confidence - Attribution::DECAY_RATE).clamp(
110
+ Attribution::CONFIDENCE_FLOOR, Attribution::CONFIDENCE_CEILING
111
+ )
112
+ a.instance_variable_set(:@confidence, new_conf)
113
+ decayed += 1
114
+ end
115
+ decayed
116
+ end
117
+
118
+ def count
119
+ @attributions.size
120
+ end
121
+
122
+ def to_h
123
+ {
124
+ total_attributions: @attributions.size,
125
+ history_size: @history.size,
126
+ outcome_counts: outcome_counts,
127
+ locus_counts: locus_counts
128
+ }
129
+ end
130
+
131
+ private
132
+
133
+ def trim_if_needed
134
+ return unless @attributions.size >= Attribution::MAX_ATTRIBUTIONS
135
+
136
+ oldest_key = @attributions.min_by { |_, a| a.created_at }&.first
137
+ @attributions.delete(oldest_key)
138
+ end
139
+
140
+ def bias_ratio(all, dimension, value, total)
141
+ all.count { |a| a.public_send(dimension) == value } / total
142
+ end
143
+
144
+ def outcome_counts
145
+ @attributions.values.group_by(&:outcome).transform_values(&:size)
146
+ end
147
+
148
+ def locus_counts
149
+ @attributions.values.group_by(&:locus).transform_values(&:size)
150
+ end
151
+ end
152
+ end
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module CausalAttribution
6
+ module Runners
7
+ module CausalAttribution
8
+ include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers) &&
9
+ Legion::Extensions::Helpers.const_defined?(:Lex)
10
+
11
+ def create_causal_attribution(event:, outcome:, domain:, locus:, stability:, controllability:,
12
+ confidence: nil, **)
13
+ conf = (confidence || Helpers::Attribution::DEFAULT_CONFIDENCE)
14
+ .clamp(Helpers::Attribution::CONFIDENCE_FLOOR, Helpers::Attribution::CONFIDENCE_CEILING)
15
+ attr = engine.create_attribution(
16
+ event: event,
17
+ outcome: outcome,
18
+ domain: domain,
19
+ locus: locus.to_sym,
20
+ stability: stability.to_sym,
21
+ controllability: controllability.to_sym,
22
+ confidence: conf
23
+ )
24
+ Legion::Logging.info "[causal_attribution] create id=#{attr.id} event=#{event} " \
25
+ "outcome=#{outcome} locus=#{locus} emotion=#{attr.emotional_response}"
26
+ { success: true, attribution: attr.to_h }
27
+ end
28
+
29
+ def reattribute_cause(attribution_id:, locus: nil, stability: nil, controllability: nil, **)
30
+ result = engine.reattribute(
31
+ attribution_id: attribution_id,
32
+ locus: locus&.to_sym,
33
+ stability: stability&.to_sym,
34
+ controllability: controllability&.to_sym
35
+ )
36
+ if result.is_a?(Hash) && result[:found] == false
37
+ Legion::Logging.warn "[causal_attribution] reattribute not_found id=#{attribution_id}"
38
+ return { success: false, attribution_id: attribution_id, found: false }
39
+ end
40
+
41
+ Legion::Logging.debug "[causal_attribution] reattribute id=#{attribution_id} " \
42
+ "locus=#{result.locus} emotion=#{result.emotional_response}"
43
+ { success: true, attribution: result.to_h }
44
+ end
45
+
46
+ def attributions_by_pattern(locus: nil, stability: nil, controllability: nil, **)
47
+ results = engine.by_pattern(
48
+ locus: locus&.to_sym,
49
+ stability: stability&.to_sym,
50
+ controllability: controllability&.to_sym
51
+ )
52
+ Legion::Logging.debug "[causal_attribution] by_pattern count=#{results.size}"
53
+ { success: true, attributions: results.map(&:to_h), count: results.size }
54
+ end
55
+
56
+ def domain_attributions(domain:, **)
57
+ results = engine.by_domain(domain: domain.to_sym)
58
+ Legion::Logging.debug "[causal_attribution] by_domain domain=#{domain} count=#{results.size}"
59
+ { success: true, attributions: results.map(&:to_h), count: results.size }
60
+ end
61
+
62
+ def outcome_attributions(outcome:, **)
63
+ results = engine.by_outcome(outcome: outcome.to_sym)
64
+ Legion::Logging.debug "[causal_attribution] by_outcome outcome=#{outcome} count=#{results.size}"
65
+ { success: true, attributions: results.map(&:to_h), count: results.size }
66
+ end
67
+
68
+ def attribution_bias_assessment(**)
69
+ bias = engine.attribution_bias
70
+ Legion::Logging.debug "[causal_attribution] bias_assessment self_serving=#{bias[:self_serving_bias_detected]}"
71
+ { success: true, bias: bias }
72
+ end
73
+
74
+ def emotional_attribution_profile(**)
75
+ profile = engine.emotional_profile
76
+ Legion::Logging.debug "[causal_attribution] emotional_profile dominant=#{profile[:dominant]} total=#{profile[:total]}"
77
+ { success: true, profile: profile }
78
+ end
79
+
80
+ def most_common_attribution(**)
81
+ result = engine.most_common_pattern
82
+ Legion::Logging.debug "[causal_attribution] most_common pattern=#{result[:pattern].inspect} count=#{result[:count]}"
83
+ { success: true, pattern: result[:pattern], count: result[:count] }
84
+ end
85
+
86
+ def update_causal_attribution(**)
87
+ decayed = engine.decay_all
88
+ Legion::Logging.debug "[causal_attribution] decay cycle entries=#{decayed}"
89
+ { success: true, decayed: decayed }
90
+ end
91
+
92
+ def causal_attribution_stats(**)
93
+ stats = engine.to_h
94
+ Legion::Logging.debug "[causal_attribution] stats total=#{stats[:total_attributions]}"
95
+ { success: true, stats: stats }
96
+ end
97
+
98
+ private
99
+
100
+ def engine
101
+ @engine ||= Helpers::AttributionEngine.new
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module CausalAttribution
6
+ VERSION = '0.1.0'
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/causal_attribution/version'
4
+ require 'legion/extensions/causal_attribution/helpers/attribution'
5
+ require 'legion/extensions/causal_attribution/helpers/attribution_engine'
6
+ require 'legion/extensions/causal_attribution/runners/causal_attribution'
7
+
8
+ module Legion
9
+ module Extensions
10
+ module CausalAttribution
11
+ extend Legion::Extensions::Core if Legion::Extensions.const_defined? :Core
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/causal_attribution/client'
4
+
5
+ RSpec.describe Legion::Extensions::CausalAttribution::Client do
6
+ let(:client) { described_class.new }
7
+
8
+ it 'responds to all runner methods' do
9
+ %i[
10
+ create_causal_attribution
11
+ reattribute_cause
12
+ attributions_by_pattern
13
+ domain_attributions
14
+ outcome_attributions
15
+ attribution_bias_assessment
16
+ emotional_attribution_profile
17
+ most_common_attribution
18
+ update_causal_attribution
19
+ causal_attribution_stats
20
+ ].each do |method|
21
+ expect(client).to respond_to(method)
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::CausalAttribution::Helpers::AttributionEngine do
4
+ subject(:engine) { described_class.new }
5
+
6
+ let(:basic_args) do
7
+ {
8
+ event: 'task completed',
9
+ outcome: :success,
10
+ domain: :work,
11
+ locus: :internal,
12
+ stability: :stable,
13
+ controllability: :controllable
14
+ }
15
+ end
16
+
17
+ describe '#create_attribution' do
18
+ it 'returns an Attribution object' do
19
+ result = engine.create_attribution(**basic_args)
20
+ expect(result).to be_a(Legion::Extensions::CausalAttribution::Helpers::Attribution)
21
+ end
22
+
23
+ it 'stores the attribution' do
24
+ engine.create_attribution(**basic_args)
25
+ expect(engine.count).to eq(1)
26
+ end
27
+
28
+ it 'accepts optional confidence' do
29
+ attr = engine.create_attribution(**basic_args, confidence: 0.9)
30
+ expect(attr.confidence).to eq(0.9)
31
+ end
32
+ end
33
+
34
+ describe '#reattribute' do
35
+ let!(:attr) { engine.create_attribution(**basic_args) }
36
+
37
+ it 'updates locus when provided' do
38
+ result = engine.reattribute(attribution_id: attr.id, locus: :external)
39
+ expect(result.locus).to eq(:external)
40
+ end
41
+
42
+ it 'preserves unchanged dimensions' do
43
+ result = engine.reattribute(attribution_id: attr.id, locus: :external)
44
+ expect(result.stability).to eq(:stable)
45
+ expect(result.controllability).to eq(:controllable)
46
+ end
47
+
48
+ it 'updates the emotional_response after reattribution' do
49
+ result = engine.reattribute(attribution_id: attr.id, locus: :external)
50
+ expect(result.emotional_response).to eq(:anger)
51
+ end
52
+
53
+ it 'returns not found hash for unknown id' do
54
+ result = engine.reattribute(attribution_id: 'nonexistent')
55
+ expect(result[:found]).to be false
56
+ end
57
+ end
58
+
59
+ describe '#by_pattern' do
60
+ before do
61
+ engine.create_attribution(**basic_args)
62
+ engine.create_attribution(**basic_args, locus: :external)
63
+ end
64
+
65
+ it 'filters by locus' do
66
+ results = engine.by_pattern(locus: :internal)
67
+ expect(results.size).to eq(1)
68
+ expect(results.first.locus).to eq(:internal)
69
+ end
70
+
71
+ it 'returns all when no filter given' do
72
+ expect(engine.by_pattern.size).to eq(2)
73
+ end
74
+ end
75
+
76
+ describe '#by_domain' do
77
+ before do
78
+ engine.create_attribution(**basic_args)
79
+ engine.create_attribution(**basic_args, domain: :personal)
80
+ end
81
+
82
+ it 'returns only matching domain' do
83
+ results = engine.by_domain(domain: :work)
84
+ expect(results.size).to eq(1)
85
+ end
86
+ end
87
+
88
+ describe '#by_outcome' do
89
+ before do
90
+ engine.create_attribution(**basic_args)
91
+ engine.create_attribution(**basic_args, outcome: :failure)
92
+ end
93
+
94
+ it 'returns only matching outcome' do
95
+ results = engine.by_outcome(outcome: :failure)
96
+ expect(results.size).to eq(1)
97
+ end
98
+ end
99
+
100
+ describe '#attribution_bias' do
101
+ it 'returns bias hash with detection key' do
102
+ engine.create_attribution(**basic_args)
103
+ result = engine.attribution_bias
104
+ expect(result).to have_key(:self_serving_bias_detected)
105
+ expect(result).to have_key(:external_failure_ratio)
106
+ end
107
+
108
+ it 'detects self-serving bias when failures are mostly external' do
109
+ 3.times do
110
+ engine.create_attribution(**basic_args, outcome: :failure, locus: :external, stability: :unstable, controllability: :controllable)
111
+ end
112
+ result = engine.attribution_bias
113
+ expect(result[:self_serving_bias_detected]).to be true
114
+ end
115
+
116
+ it 'returns no attributions message when empty' do
117
+ result = engine.attribution_bias
118
+ expect(result[:bias]).to eq(:none)
119
+ end
120
+ end
121
+
122
+ describe '#emotional_profile' do
123
+ before do
124
+ engine.create_attribution(**basic_args)
125
+ engine.create_attribution(**basic_args, locus: :external, stability: :unstable)
126
+ end
127
+
128
+ it 'returns distribution and dominant' do
129
+ result = engine.emotional_profile
130
+ expect(result).to have_key(:distribution)
131
+ expect(result).to have_key(:dominant)
132
+ expect(result[:total]).to eq(2)
133
+ end
134
+ end
135
+
136
+ describe '#most_common_pattern' do
137
+ before do
138
+ 2.times { engine.create_attribution(**basic_args) }
139
+ engine.create_attribution(**basic_args, locus: :external)
140
+ end
141
+
142
+ it 'returns the most frequent pattern' do
143
+ result = engine.most_common_pattern
144
+ expect(result[:pattern]).to eq(%i[internal stable controllable])
145
+ expect(result[:count]).to eq(2)
146
+ end
147
+
148
+ it 'returns nil pattern when empty' do
149
+ expect(described_class.new.most_common_pattern[:pattern]).to be_nil
150
+ end
151
+ end
152
+
153
+ describe '#decay_all' do
154
+ it 'reduces confidence on all attributions' do
155
+ engine.create_attribution(**basic_args, confidence: 0.8)
156
+ engine.decay_all
157
+ expect(engine.by_pattern.first.confidence).to be < 0.8
158
+ end
159
+
160
+ it 'returns count of decayed entries' do
161
+ 2.times { engine.create_attribution(**basic_args) }
162
+ expect(engine.decay_all).to eq(2)
163
+ end
164
+
165
+ it 'floors at CONFIDENCE_FLOOR' do
166
+ attr = engine.create_attribution(**basic_args, confidence: 0.001)
167
+ engine.decay_all
168
+ expect(attr.confidence).to be >= Legion::Extensions::CausalAttribution::Helpers::Attribution::CONFIDENCE_FLOOR
169
+ end
170
+ end
171
+
172
+ describe '#to_h' do
173
+ it 'includes stats keys' do
174
+ engine.create_attribution(**basic_args)
175
+ h = engine.to_h
176
+ expect(h).to have_key(:total_attributions)
177
+ expect(h).to have_key(:outcome_counts)
178
+ expect(h).to have_key(:locus_counts)
179
+ end
180
+ end
181
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::CausalAttribution::Helpers::Attribution do
4
+ subject(:attr) do
5
+ described_class.new(
6
+ event: 'task failed',
7
+ outcome: :failure,
8
+ domain: :work,
9
+ locus: :internal,
10
+ stability: :stable,
11
+ controllability: :controllable
12
+ )
13
+ end
14
+
15
+ describe '#initialize' do
16
+ it 'assigns an id' do
17
+ expect(attr.id).to match(/\A[0-9a-f-]{36}\z/)
18
+ end
19
+
20
+ it 'stores all dimensions' do
21
+ expect(attr.locus).to eq(:internal)
22
+ expect(attr.stability).to eq(:stable)
23
+ expect(attr.controllability).to eq(:controllable)
24
+ end
25
+
26
+ it 'derives emotional_response from ATTRIBUTION_EMOTIONS' do
27
+ expect(attr.emotional_response).to eq(:guilt)
28
+ end
29
+
30
+ it 'uses DEFAULT_CONFIDENCE when none given' do
31
+ expect(attr.confidence).to eq(described_class::DEFAULT_CONFIDENCE)
32
+ end
33
+
34
+ it 'clamps confidence to ceiling' do
35
+ high = described_class.new(
36
+ event: 'x', outcome: :success, domain: :test,
37
+ locus: :external, stability: :unstable, controllability: :uncontrollable,
38
+ confidence: 5.0
39
+ )
40
+ expect(high.confidence).to eq(1.0)
41
+ end
42
+
43
+ it 'clamps confidence to floor' do
44
+ low = described_class.new(
45
+ event: 'x', outcome: :success, domain: :test,
46
+ locus: :external, stability: :unstable, controllability: :uncontrollable,
47
+ confidence: -1.0
48
+ )
49
+ expect(low.confidence).to eq(0.0)
50
+ end
51
+
52
+ it 'records created_at' do
53
+ expect(attr.created_at).to be_a(Time)
54
+ end
55
+ end
56
+
57
+ describe '#pattern' do
58
+ it 'returns [locus, stability, controllability] tuple' do
59
+ expect(attr.pattern).to eq(%i[internal stable controllable])
60
+ end
61
+ end
62
+
63
+ describe 'predicates' do
64
+ it 'internal? returns true for internal locus' do
65
+ expect(attr.internal?).to be true
66
+ expect(attr.external?).to be false
67
+ end
68
+
69
+ it 'stable? returns true for stable stability' do
70
+ expect(attr.stable?).to be true
71
+ end
72
+
73
+ it 'controllable? returns true for controllable controllability' do
74
+ expect(attr.controllable?).to be true
75
+ end
76
+ end
77
+
78
+ describe 'ATTRIBUTION_EMOTIONS mapping' do
79
+ {
80
+ %i[internal stable controllable] => :guilt,
81
+ %i[internal stable uncontrollable] => :shame,
82
+ %i[internal unstable controllable] => :regret,
83
+ %i[internal unstable uncontrollable] => :surprise,
84
+ %i[external stable controllable] => :anger,
85
+ %i[external stable uncontrollable] => :helplessness,
86
+ %i[external unstable controllable] => :frustration,
87
+ %i[external unstable uncontrollable] => :relief
88
+ }.each do |pattern, emotion|
89
+ it "maps #{pattern.inspect} to #{emotion}" do
90
+ a = described_class.new(
91
+ event: 'e', outcome: :neutral, domain: :test,
92
+ locus: pattern[0], stability: pattern[1], controllability: pattern[2]
93
+ )
94
+ expect(a.emotional_response).to eq(emotion)
95
+ end
96
+ end
97
+ end
98
+
99
+ describe '#to_h' do
100
+ it 'includes all required keys' do
101
+ h = attr.to_h
102
+ %i[id event outcome domain locus stability controllability
103
+ confidence emotional_response created_at].each do |key|
104
+ expect(h).to have_key(key)
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/causal_attribution/client'
4
+
5
+ RSpec.describe Legion::Extensions::CausalAttribution::Runners::CausalAttribution do
6
+ let(:client) { Legion::Extensions::CausalAttribution::Client.new }
7
+
8
+ let(:base_args) do
9
+ {
10
+ event: 'missed deadline',
11
+ outcome: :failure,
12
+ domain: :work,
13
+ locus: :internal,
14
+ stability: :stable,
15
+ controllability: :controllable
16
+ }
17
+ end
18
+
19
+ describe '#create_causal_attribution' do
20
+ it 'returns success true' do
21
+ result = client.create_causal_attribution(**base_args)
22
+ expect(result[:success]).to be true
23
+ end
24
+
25
+ it 'includes attribution hash' do
26
+ result = client.create_causal_attribution(**base_args)
27
+ expect(result[:attribution]).to have_key(:id)
28
+ expect(result[:attribution][:emotional_response]).to eq(:guilt)
29
+ end
30
+
31
+ it 'accepts string locus and coerces to symbol' do
32
+ result = client.create_causal_attribution(**base_args, locus: 'external')
33
+ expect(result[:attribution][:locus]).to eq(:external)
34
+ end
35
+ end
36
+
37
+ describe '#reattribute_cause' do
38
+ let!(:id) { client.create_causal_attribution(**base_args)[:attribution][:id] }
39
+
40
+ it 'updates the attribution' do
41
+ result = client.reattribute_cause(attribution_id: id, locus: :external)
42
+ expect(result[:success]).to be true
43
+ expect(result[:attribution][:locus]).to eq(:external)
44
+ end
45
+
46
+ it 'returns success false for unknown id' do
47
+ result = client.reattribute_cause(attribution_id: 'bad-id')
48
+ expect(result[:success]).to be false
49
+ expect(result[:found]).to be false
50
+ end
51
+ end
52
+
53
+ describe '#attributions_by_pattern' do
54
+ before do
55
+ client.create_causal_attribution(**base_args)
56
+ client.create_causal_attribution(**base_args, locus: :external)
57
+ end
58
+
59
+ it 'filters by locus' do
60
+ result = client.attributions_by_pattern(locus: :internal)
61
+ expect(result[:count]).to eq(1)
62
+ end
63
+
64
+ it 'returns all when no filter given' do
65
+ result = client.attributions_by_pattern
66
+ expect(result[:count]).to eq(2)
67
+ end
68
+ end
69
+
70
+ describe '#domain_attributions' do
71
+ before { client.create_causal_attribution(**base_args) }
72
+
73
+ it 'returns attributions for domain' do
74
+ result = client.domain_attributions(domain: :work)
75
+ expect(result[:count]).to be >= 1
76
+ end
77
+ end
78
+
79
+ describe '#outcome_attributions' do
80
+ before do
81
+ client.create_causal_attribution(**base_args)
82
+ client.create_causal_attribution(**base_args, outcome: :success)
83
+ end
84
+
85
+ it 'returns attributions by outcome' do
86
+ result = client.outcome_attributions(outcome: :failure)
87
+ expect(result[:count]).to eq(1)
88
+ end
89
+ end
90
+
91
+ describe '#attribution_bias_assessment' do
92
+ before { client.create_causal_attribution(**base_args) }
93
+
94
+ it 'returns bias structure' do
95
+ result = client.attribution_bias_assessment
96
+ expect(result[:success]).to be true
97
+ expect(result[:bias]).to have_key(:self_serving_bias_detected)
98
+ end
99
+ end
100
+
101
+ describe '#emotional_attribution_profile' do
102
+ before { client.create_causal_attribution(**base_args) }
103
+
104
+ it 'returns profile with dominant emotion' do
105
+ result = client.emotional_attribution_profile
106
+ expect(result[:success]).to be true
107
+ expect(result[:profile]).to have_key(:dominant)
108
+ end
109
+ end
110
+
111
+ describe '#most_common_attribution' do
112
+ before do
113
+ 2.times { client.create_causal_attribution(**base_args) }
114
+ end
115
+
116
+ it 'returns the most frequent pattern' do
117
+ result = client.most_common_attribution
118
+ expect(result[:success]).to be true
119
+ expect(result[:count]).to eq(2)
120
+ end
121
+ end
122
+
123
+ describe '#update_causal_attribution' do
124
+ before { client.create_causal_attribution(**base_args, confidence: 0.8) }
125
+
126
+ it 'decays confidence and reports count' do
127
+ result = client.update_causal_attribution
128
+ expect(result[:success]).to be true
129
+ expect(result[:decayed]).to eq(1)
130
+ end
131
+ end
132
+
133
+ describe '#causal_attribution_stats' do
134
+ before { client.create_causal_attribution(**base_args) }
135
+
136
+ it 'returns stats hash' do
137
+ result = client.causal_attribution_stats
138
+ expect(result[:success]).to be true
139
+ expect(result[:stats][:total_attributions]).to eq(1)
140
+ end
141
+ end
142
+ 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/causal_attribution'
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,74 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: lex-causal-attribution
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: Weiner's attribution theory for brain-modeled agentic AI — locus, stability,
27
+ controllability
28
+ email:
29
+ - matthewdiverson@gmail.com
30
+ executables: []
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - Gemfile
35
+ - lex-causal-attribution.gemspec
36
+ - lib/legion/extensions/causal_attribution.rb
37
+ - lib/legion/extensions/causal_attribution/client.rb
38
+ - lib/legion/extensions/causal_attribution/helpers/attribution.rb
39
+ - lib/legion/extensions/causal_attribution/helpers/attribution_engine.rb
40
+ - lib/legion/extensions/causal_attribution/runners/causal_attribution.rb
41
+ - lib/legion/extensions/causal_attribution/version.rb
42
+ - spec/legion/extensions/causal_attribution/client_spec.rb
43
+ - spec/legion/extensions/causal_attribution/helpers/attribution_engine_spec.rb
44
+ - spec/legion/extensions/causal_attribution/helpers/attribution_spec.rb
45
+ - spec/legion/extensions/causal_attribution/runners/causal_attribution_spec.rb
46
+ - spec/spec_helper.rb
47
+ homepage: https://github.com/LegionIO/lex-causal-attribution
48
+ licenses:
49
+ - MIT
50
+ metadata:
51
+ homepage_uri: https://github.com/LegionIO/lex-causal-attribution
52
+ source_code_uri: https://github.com/LegionIO/lex-causal-attribution
53
+ documentation_uri: https://github.com/LegionIO/lex-causal-attribution
54
+ changelog_uri: https://github.com/LegionIO/lex-causal-attribution
55
+ bug_tracker_uri: https://github.com/LegionIO/lex-causal-attribution/issues
56
+ rubygems_mfa_required: 'true'
57
+ rdoc_options: []
58
+ require_paths:
59
+ - lib
60
+ required_ruby_version: !ruby/object:Gem::Requirement
61
+ requirements:
62
+ - - ">="
63
+ - !ruby/object:Gem::Version
64
+ version: '3.4'
65
+ required_rubygems_version: !ruby/object:Gem::Requirement
66
+ requirements:
67
+ - - ">="
68
+ - !ruby/object:Gem::Version
69
+ version: '0'
70
+ requirements: []
71
+ rubygems_version: 3.6.9
72
+ specification_version: 4
73
+ summary: LEX Causal Attribution
74
+ test_files: []