lex-bias 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/LICENSE +21 -0
- data/lex-bias.gemspec +29 -0
- data/lib/legion/extensions/bias/actors/update.rb +41 -0
- data/lib/legion/extensions/bias/client.rb +26 -0
- data/lib/legion/extensions/bias/helpers/bias_detector.rb +103 -0
- data/lib/legion/extensions/bias/helpers/bias_event.rb +40 -0
- data/lib/legion/extensions/bias/helpers/bias_store.rb +80 -0
- data/lib/legion/extensions/bias/helpers/constants.rb +24 -0
- data/lib/legion/extensions/bias/runners/bias.rb +147 -0
- data/lib/legion/extensions/bias/version.rb +9 -0
- data/lib/legion/extensions/bias.rb +16 -0
- data/spec/legion/extensions/bias/client_spec.rb +16 -0
- data/spec/legion/extensions/bias/helpers/bias_detector_spec.rb +160 -0
- data/spec/legion/extensions/bias/helpers/bias_event_spec.rb +64 -0
- data/spec/legion/extensions/bias/helpers/bias_store_spec.rb +143 -0
- data/spec/legion/extensions/bias/runners/bias_spec.rb +155 -0
- data/spec/spec_helper.rb +20 -0
- metadata +78 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 6f5685a3afbc78717dc5f496538693e629fcdd155a5c5cbb61ee2d0d46bfe721
|
|
4
|
+
data.tar.gz: 45d211f0ae027cfdd83cf3dde20f08b64630cbee1a14366508994ac6f76b36e9
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 64c5161d46db36774416a9c22298be0d120b0e7f75df0a6ac1039696adc09c3ead53e4dd6f555580bbb3ad8597c432b215437ad0d59a205ee5a2c42d0e89abbf
|
|
7
|
+
data.tar.gz: 7297fe2cdf218a2c88bb26c92e9a5a24b654dd1d0115bfd2cfa27106c2efbae3af86e2148dbe0f9c0316b5cf0c60412e17feb804e1c2fa8cb70f98f3cfc97480
|
data/Gemfile
ADDED
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Matthew Iverson
|
|
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/lex-bias.gemspec
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'lib/legion/extensions/bias/version'
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = 'lex-bias'
|
|
7
|
+
spec.version = Legion::Extensions::Bias::VERSION
|
|
8
|
+
spec.authors = ['Esity']
|
|
9
|
+
spec.email = ['matthewdiverson@gmail.com']
|
|
10
|
+
|
|
11
|
+
spec.summary = 'LEX Bias'
|
|
12
|
+
spec.description = 'Cognitive bias detection and correction for brain-modeled agentic AI'
|
|
13
|
+
spec.homepage = 'https://github.com/LegionIO/lex-bias'
|
|
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-bias'
|
|
19
|
+
spec.metadata['documentation_uri'] = 'https://github.com/LegionIO/lex-bias'
|
|
20
|
+
spec.metadata['changelog_uri'] = 'https://github.com/LegionIO/lex-bias'
|
|
21
|
+
spec.metadata['bug_tracker_uri'] = 'https://github.com/LegionIO/lex-bias/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-bias.gemspec Gemfile LICENSE]
|
|
26
|
+
end
|
|
27
|
+
spec.require_paths = ['lib']
|
|
28
|
+
spec.add_development_dependency 'legion-gaia'
|
|
29
|
+
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 Bias
|
|
8
|
+
module Actor
|
|
9
|
+
class Update < Legion::Extensions::Actors::Every
|
|
10
|
+
def runner_class
|
|
11
|
+
Legion::Extensions::Bias::Runners::Bias
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def runner_function
|
|
15
|
+
'update_bias'
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def time
|
|
19
|
+
60
|
|
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,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/bias/helpers/constants'
|
|
4
|
+
require 'legion/extensions/bias/helpers/bias_event'
|
|
5
|
+
require 'legion/extensions/bias/helpers/bias_detector'
|
|
6
|
+
require 'legion/extensions/bias/helpers/bias_store'
|
|
7
|
+
require 'legion/extensions/bias/runners/bias'
|
|
8
|
+
|
|
9
|
+
module Legion
|
|
10
|
+
module Extensions
|
|
11
|
+
module Bias
|
|
12
|
+
class Client
|
|
13
|
+
include Runners::Bias
|
|
14
|
+
|
|
15
|
+
def initialize(**)
|
|
16
|
+
@bias_detector = Helpers::BiasDetector.new
|
|
17
|
+
@bias_store = Helpers::BiasStore.new
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
attr_reader :bias_detector, :bias_store
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Bias
|
|
6
|
+
module Helpers
|
|
7
|
+
class BiasDetector
|
|
8
|
+
include Constants
|
|
9
|
+
|
|
10
|
+
def initialize
|
|
11
|
+
@susceptibility = Constants::BIAS_TYPES.to_h { |b| [b, Constants::DEFAULT_SUSCEPTIBILITY] }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def detect_anchoring(current_value:, anchor_value:, domain: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
15
|
+
return 0.0 if anchor_value.nil? || anchor_value.zero?
|
|
16
|
+
|
|
17
|
+
distance = (current_value - anchor_value).abs.to_f / anchor_value.abs
|
|
18
|
+
pull = 1.0 - distance.clamp(0.0, 1.0)
|
|
19
|
+
magnitude = pull * susceptibility_for(:anchoring)
|
|
20
|
+
update_susceptibility(:anchoring, detected: magnitude >= Constants::DETECTION_THRESHOLD)
|
|
21
|
+
magnitude.clamp(0.0, 1.0)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def detect_confirmation(evidence_direction:, hypothesis_direction:, domain: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
25
|
+
magnitude = if evidence_direction == hypothesis_direction
|
|
26
|
+
Constants::CONFIRMATION_WEIGHT * susceptibility_for(:confirmation)
|
|
27
|
+
else
|
|
28
|
+
(1.0 - Constants::CONFIRMATION_WEIGHT) * susceptibility_for(:confirmation)
|
|
29
|
+
end
|
|
30
|
+
update_susceptibility(:confirmation, detected: magnitude >= Constants::DETECTION_THRESHOLD)
|
|
31
|
+
magnitude.clamp(0.0, 1.0)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def detect_availability(recent_events:, domain: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
35
|
+
window = Constants::AVAILABILITY_RECENCY_WINDOW
|
|
36
|
+
density = [recent_events.size, window].min.to_f / window
|
|
37
|
+
magnitude = density * susceptibility_for(:availability)
|
|
38
|
+
update_susceptibility(:availability, detected: magnitude >= Constants::DETECTION_THRESHOLD)
|
|
39
|
+
magnitude.clamp(0.0, 1.0)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def detect_recency(data_points:, domain: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
43
|
+
return 0.0 if data_points.size < 2
|
|
44
|
+
|
|
45
|
+
total = data_points.size
|
|
46
|
+
half = total / 2
|
|
47
|
+
recent_half = data_points.last(half)
|
|
48
|
+
earlier_half = data_points.first(half)
|
|
49
|
+
|
|
50
|
+
recent_mean = mean(recent_half)
|
|
51
|
+
earlier_mean = mean(earlier_half)
|
|
52
|
+
|
|
53
|
+
range = (data_points.max - data_points.min).to_f
|
|
54
|
+
return 0.0 if range.zero?
|
|
55
|
+
|
|
56
|
+
skew = (recent_mean - earlier_mean).abs / range
|
|
57
|
+
magnitude = skew * susceptibility_for(:recency)
|
|
58
|
+
update_susceptibility(:recency, detected: magnitude >= Constants::DETECTION_THRESHOLD)
|
|
59
|
+
magnitude.clamp(0.0, 1.0)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def detect_sunk_cost(invested:, expected_return:, domain: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
63
|
+
return 0.0 if invested <= 0
|
|
64
|
+
|
|
65
|
+
ratio = invested.to_f / (invested + expected_return.abs + 1.0)
|
|
66
|
+
magnitude = ratio * susceptibility_for(:sunk_cost)
|
|
67
|
+
update_susceptibility(:sunk_cost, detected: magnitude >= Constants::DETECTION_THRESHOLD)
|
|
68
|
+
magnitude.clamp(0.0, 1.0)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def susceptibility_for(bias_type)
|
|
72
|
+
@susceptibility.fetch(bias_type, Constants::DEFAULT_SUSCEPTIBILITY)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def update_susceptibility(bias_type, detected:)
|
|
76
|
+
return unless @susceptibility.key?(bias_type)
|
|
77
|
+
|
|
78
|
+
alpha = Constants::SUSCEPTIBILITY_ALPHA
|
|
79
|
+
signal = detected ? 1.0 : 0.0
|
|
80
|
+
current = @susceptibility[bias_type]
|
|
81
|
+
@susceptibility[bias_type] = ((alpha * signal) + ((1.0 - alpha) * current)).clamp(0.0, 1.0)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def correction_for(magnitude)
|
|
85
|
+
(magnitude * Constants::CORRECTION_FACTOR).clamp(0.0, 1.0)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def to_h
|
|
89
|
+
{ susceptibility: @susceptibility.dup }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private
|
|
93
|
+
|
|
94
|
+
def mean(values)
|
|
95
|
+
return 0.0 if values.empty?
|
|
96
|
+
|
|
97
|
+
values.sum.to_f / values.size
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module Extensions
|
|
7
|
+
module Bias
|
|
8
|
+
module Helpers
|
|
9
|
+
class BiasEvent
|
|
10
|
+
attr_reader :id, :bias_type, :domain, :magnitude, :corrected,
|
|
11
|
+
:correction_applied, :context, :timestamp
|
|
12
|
+
|
|
13
|
+
def initialize(bias_type:, domain:, magnitude:, **opts)
|
|
14
|
+
@id = SecureRandom.uuid
|
|
15
|
+
@bias_type = bias_type
|
|
16
|
+
@domain = domain
|
|
17
|
+
@magnitude = magnitude
|
|
18
|
+
@corrected = opts.fetch(:corrected, false)
|
|
19
|
+
@correction_applied = opts.fetch(:correction_applied, 0.0)
|
|
20
|
+
@context = opts.fetch(:context, {})
|
|
21
|
+
@timestamp = Time.now.utc
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def to_h
|
|
25
|
+
{
|
|
26
|
+
id: @id,
|
|
27
|
+
bias_type: @bias_type,
|
|
28
|
+
domain: @domain,
|
|
29
|
+
magnitude: @magnitude,
|
|
30
|
+
corrected: @corrected,
|
|
31
|
+
correction_applied: @correction_applied,
|
|
32
|
+
context: @context,
|
|
33
|
+
timestamp: @timestamp
|
|
34
|
+
}
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Bias
|
|
6
|
+
module Helpers
|
|
7
|
+
class BiasStore
|
|
8
|
+
def initialize
|
|
9
|
+
@events = []
|
|
10
|
+
@anchors = {} # domain -> array of { value:, influence:, recorded_at: }
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def record(event)
|
|
14
|
+
@events << event
|
|
15
|
+
@events.shift while @events.size > Constants::MAX_BIAS_EVENTS
|
|
16
|
+
event
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def recent(count = 10)
|
|
20
|
+
@events.last(count).map(&:to_h)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def by_type(bias_type)
|
|
24
|
+
@events.select { |e| e.bias_type == bias_type }.map(&:to_h)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def by_domain(domain)
|
|
28
|
+
@events.select { |e| e.domain == domain }.map(&:to_h)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def register_anchor(domain, value:, influence: 1.0)
|
|
32
|
+
@anchors[domain] ||= []
|
|
33
|
+
@anchors[domain] << { value: value, influence: influence, recorded_at: Time.now.utc }
|
|
34
|
+
@anchors[domain].shift while @anchors[domain].size > Constants::MAX_ANCHORS
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def anchors_for(domain)
|
|
38
|
+
@anchors.fetch(domain, [])
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def decay_anchors
|
|
42
|
+
@anchors.each_value do |anchor_list|
|
|
43
|
+
anchor_list.each { |a| a[:influence] = (a[:influence] - Constants::ANCHOR_DECAY).clamp(0.0, 1.0) }
|
|
44
|
+
anchor_list.reject! { |a| a[:influence] <= 0.0 }
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def stats
|
|
49
|
+
return { total: 0, by_type: {}, by_domain: {} } if @events.empty?
|
|
50
|
+
|
|
51
|
+
by_type = Constants::BIAS_TYPES.to_h do |bt|
|
|
52
|
+
events = @events.select { |e| e.bias_type == bt }
|
|
53
|
+
avg_mag = events.empty? ? 0.0 : events.sum(&:magnitude) / events.size
|
|
54
|
+
[bt, { count: events.size, avg_magnitude: avg_mag.round(4) }]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
domains = @events.map(&:domain).uniq
|
|
58
|
+
by_domain = domains.to_h do |d|
|
|
59
|
+
events = @events.select { |e| e.domain == d }
|
|
60
|
+
[d, { count: events.size }]
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
{
|
|
64
|
+
total: @events.size,
|
|
65
|
+
by_type: by_type,
|
|
66
|
+
by_domain: by_domain
|
|
67
|
+
}
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def to_h
|
|
71
|
+
{
|
|
72
|
+
total_events: @events.size,
|
|
73
|
+
anchor_domains: @anchors.keys
|
|
74
|
+
}
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Bias
|
|
6
|
+
module Helpers
|
|
7
|
+
module Constants
|
|
8
|
+
BIAS_TYPES = %i[anchoring confirmation availability recency sunk_cost].freeze
|
|
9
|
+
|
|
10
|
+
DETECTION_THRESHOLD = 0.3 # above this = bias likely influencing decision
|
|
11
|
+
CORRECTION_FACTOR = 0.5 # how much to correct when bias detected
|
|
12
|
+
DEFAULT_SUSCEPTIBILITY = 0.5 # starting susceptibility per bias
|
|
13
|
+
SUSCEPTIBILITY_ALPHA = 0.1 # EMA alpha for updating susceptibility
|
|
14
|
+
DECAY_RATE = 0.02 # how fast bias activation decays per tick
|
|
15
|
+
MAX_BIAS_EVENTS = 200 # max tracked bias events
|
|
16
|
+
MAX_ANCHORS = 50 # max tracked anchor values
|
|
17
|
+
ANCHOR_DECAY = 0.05 # how fast anchor influence decays
|
|
18
|
+
CONFIRMATION_WEIGHT = 0.7 # weight of confirming vs disconfirming evidence
|
|
19
|
+
AVAILABILITY_RECENCY_WINDOW = 10 # recent events window for availability heuristic
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Bias
|
|
6
|
+
module Runners
|
|
7
|
+
module Bias
|
|
8
|
+
include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers) &&
|
|
9
|
+
Legion::Extensions::Helpers.const_defined?(:Lex)
|
|
10
|
+
|
|
11
|
+
def check_for_bias(domain:, decision_context: {}, **)
|
|
12
|
+
Legion::Logging.debug "[bias] check_for_bias domain=#{domain}"
|
|
13
|
+
detected = collect_bias_detections(domain, decision_context)
|
|
14
|
+
active = detected.select { |b| b[:magnitude] >= Helpers::Constants::DETECTION_THRESHOLD }
|
|
15
|
+
Legion::Logging.debug "[bias] check_for_bias domain=#{domain} detected=#{active.size}"
|
|
16
|
+
{ success: true, domain: domain, detected: active, all: detected }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def record_anchor(domain:, value:, **)
|
|
20
|
+
bias_store.register_anchor(domain, value: value)
|
|
21
|
+
Legion::Logging.debug "[bias] anchor recorded domain=#{domain} value=#{value}"
|
|
22
|
+
{ success: true, domain: domain, value: value }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def update_bias(**)
|
|
26
|
+
bias_store.decay_anchors
|
|
27
|
+
Legion::Logging.debug '[bias] update_bias: anchors decayed'
|
|
28
|
+
{ success: true }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def bias_report(domain: nil, **)
|
|
32
|
+
events = domain ? bias_store.by_domain(domain) : bias_store.recent(50)
|
|
33
|
+
Legion::Logging.debug "[bias] bias_report domain=#{domain.inspect} events=#{events.size}"
|
|
34
|
+
{ success: true, domain: domain, events: events, count: events.size }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def susceptibility_profile(**)
|
|
38
|
+
profile = bias_detector.to_h
|
|
39
|
+
Legion::Logging.debug '[bias] susceptibility_profile'
|
|
40
|
+
{ success: true, **profile }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def bias_stats(**)
|
|
44
|
+
stats = bias_store.stats
|
|
45
|
+
Legion::Logging.debug "[bias] bias_stats total=#{stats[:total]}"
|
|
46
|
+
{ success: true, **stats }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def bias_detector
|
|
52
|
+
@bias_detector ||= Helpers::BiasDetector.new
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def bias_store
|
|
56
|
+
@bias_store ||= Helpers::BiasStore.new
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def collect_bias_detections(domain, ctx)
|
|
60
|
+
results = []
|
|
61
|
+
results.concat(detect_anchoring_bias(domain, ctx))
|
|
62
|
+
results.concat(detect_confirmation_bias(domain, ctx))
|
|
63
|
+
results.concat(detect_availability_bias(domain, ctx))
|
|
64
|
+
results.concat(detect_recency_bias(domain, ctx))
|
|
65
|
+
results.concat(detect_sunk_cost_bias(domain, ctx))
|
|
66
|
+
results
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def detect_anchoring_bias(domain, ctx)
|
|
70
|
+
anchors = bias_store.anchors_for(domain)
|
|
71
|
+
return [] unless anchors.any? && ctx[:current_value]
|
|
72
|
+
|
|
73
|
+
anchor_value = anchors.max_by { |a| a[:influence] }&.dig(:value)
|
|
74
|
+
return [] unless anchor_value
|
|
75
|
+
|
|
76
|
+
mag = bias_detector.detect_anchoring(
|
|
77
|
+
current_value: ctx[:current_value],
|
|
78
|
+
anchor_value: anchor_value,
|
|
79
|
+
domain: domain
|
|
80
|
+
)
|
|
81
|
+
[build_bias_result(:anchoring, domain, mag, ctx)]
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def detect_confirmation_bias(domain, ctx)
|
|
85
|
+
return [] unless ctx[:evidence_direction] && ctx[:hypothesis_direction]
|
|
86
|
+
|
|
87
|
+
mag = bias_detector.detect_confirmation(
|
|
88
|
+
evidence_direction: ctx[:evidence_direction],
|
|
89
|
+
hypothesis_direction: ctx[:hypothesis_direction],
|
|
90
|
+
domain: domain
|
|
91
|
+
)
|
|
92
|
+
[build_bias_result(:confirmation, domain, mag, ctx)]
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def detect_availability_bias(domain, ctx)
|
|
96
|
+
return [] unless ctx[:recent_events]
|
|
97
|
+
|
|
98
|
+
mag = bias_detector.detect_availability(recent_events: ctx[:recent_events], domain: domain)
|
|
99
|
+
[build_bias_result(:availability, domain, mag, ctx)]
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def detect_recency_bias(domain, ctx)
|
|
103
|
+
return [] unless ctx[:data_points]
|
|
104
|
+
|
|
105
|
+
mag = bias_detector.detect_recency(data_points: ctx[:data_points], domain: domain)
|
|
106
|
+
[build_bias_result(:recency, domain, mag, ctx)]
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def detect_sunk_cost_bias(domain, ctx)
|
|
110
|
+
return [] unless ctx[:invested] && !ctx[:expected_return].nil?
|
|
111
|
+
|
|
112
|
+
mag = bias_detector.detect_sunk_cost(
|
|
113
|
+
invested: ctx[:invested],
|
|
114
|
+
expected_return: ctx[:expected_return],
|
|
115
|
+
domain: domain
|
|
116
|
+
)
|
|
117
|
+
[build_bias_result(:sunk_cost, domain, mag, ctx)]
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def build_bias_result(bias_type, domain, magnitude, context)
|
|
121
|
+
correction = bias_detector.correction_for(magnitude)
|
|
122
|
+
corrected = magnitude >= Helpers::Constants::DETECTION_THRESHOLD
|
|
123
|
+
|
|
124
|
+
if corrected
|
|
125
|
+
event = Helpers::BiasEvent.new(
|
|
126
|
+
bias_type: bias_type,
|
|
127
|
+
domain: domain,
|
|
128
|
+
magnitude: magnitude,
|
|
129
|
+
corrected: corrected,
|
|
130
|
+
correction_applied: correction,
|
|
131
|
+
context: context
|
|
132
|
+
)
|
|
133
|
+
bias_store.record(event)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
{
|
|
137
|
+
bias_type: bias_type,
|
|
138
|
+
magnitude: magnitude,
|
|
139
|
+
corrected: corrected,
|
|
140
|
+
correction_applied: correction
|
|
141
|
+
}
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/bias/version'
|
|
4
|
+
require 'legion/extensions/bias/helpers/constants'
|
|
5
|
+
require 'legion/extensions/bias/helpers/bias_event'
|
|
6
|
+
require 'legion/extensions/bias/helpers/bias_detector'
|
|
7
|
+
require 'legion/extensions/bias/helpers/bias_store'
|
|
8
|
+
require 'legion/extensions/bias/runners/bias'
|
|
9
|
+
|
|
10
|
+
module Legion
|
|
11
|
+
module Extensions
|
|
12
|
+
module Bias
|
|
13
|
+
extend Legion::Extensions::Core if Legion::Extensions.const_defined? :Core
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/bias/client'
|
|
4
|
+
|
|
5
|
+
RSpec.describe Legion::Extensions::Bias::Client do
|
|
6
|
+
let(:client) { described_class.new }
|
|
7
|
+
|
|
8
|
+
it 'responds to all runner methods' do
|
|
9
|
+
expect(client).to respond_to(:check_for_bias)
|
|
10
|
+
expect(client).to respond_to(:record_anchor)
|
|
11
|
+
expect(client).to respond_to(:update_bias)
|
|
12
|
+
expect(client).to respond_to(:bias_report)
|
|
13
|
+
expect(client).to respond_to(:susceptibility_profile)
|
|
14
|
+
expect(client).to respond_to(:bias_stats)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/bias/helpers/constants'
|
|
4
|
+
require 'legion/extensions/bias/helpers/bias_detector'
|
|
5
|
+
|
|
6
|
+
RSpec.describe Legion::Extensions::Bias::Helpers::BiasDetector do
|
|
7
|
+
subject(:detector) { described_class.new }
|
|
8
|
+
|
|
9
|
+
describe '#susceptibility_for' do
|
|
10
|
+
it 'returns DEFAULT_SUSCEPTIBILITY for all bias types at init' do
|
|
11
|
+
Legion::Extensions::Bias::Helpers::Constants::BIAS_TYPES.each do |bt|
|
|
12
|
+
expect(detector.susceptibility_for(bt)).to eq(
|
|
13
|
+
Legion::Extensions::Bias::Helpers::Constants::DEFAULT_SUSCEPTIBILITY
|
|
14
|
+
)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
describe '#detect_anchoring' do
|
|
20
|
+
it 'returns high magnitude when current value is very close to anchor' do
|
|
21
|
+
mag = detector.detect_anchoring(current_value: 100.0, anchor_value: 100.0)
|
|
22
|
+
expect(mag).to be_within(0.01).of(0.5) # pull=1.0 * susceptibility=0.5
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
it 'returns low magnitude when current value is far from anchor' do
|
|
26
|
+
mag = detector.detect_anchoring(current_value: 200.0, anchor_value: 100.0)
|
|
27
|
+
expect(mag).to be < 0.3
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
it 'returns 0.0 when anchor_value is zero' do
|
|
31
|
+
mag = detector.detect_anchoring(current_value: 50.0, anchor_value: 0.0)
|
|
32
|
+
expect(mag).to eq(0.0)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
it 'returns 0.0 when anchor_value is nil' do
|
|
36
|
+
mag = detector.detect_anchoring(current_value: 50.0, anchor_value: nil)
|
|
37
|
+
expect(mag).to eq(0.0)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
it 'clamps result to [0, 1]' do
|
|
41
|
+
mag = detector.detect_anchoring(current_value: 100.0, anchor_value: 100.0)
|
|
42
|
+
expect(mag).to be_between(0.0, 1.0)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
describe '#detect_confirmation' do
|
|
47
|
+
it 'returns higher magnitude when evidence matches hypothesis' do
|
|
48
|
+
mag_match = detector.detect_confirmation(
|
|
49
|
+
evidence_direction: :positive,
|
|
50
|
+
hypothesis_direction: :positive
|
|
51
|
+
)
|
|
52
|
+
mag_mismatch = detector.detect_confirmation(
|
|
53
|
+
evidence_direction: :negative,
|
|
54
|
+
hypothesis_direction: :positive
|
|
55
|
+
)
|
|
56
|
+
expect(mag_match).to be > mag_mismatch
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
it 'returns magnitude >= CONFIRMATION_WEIGHT * susceptibility when matching' do
|
|
60
|
+
mag = detector.detect_confirmation(
|
|
61
|
+
evidence_direction: :up,
|
|
62
|
+
hypothesis_direction: :up
|
|
63
|
+
)
|
|
64
|
+
expected = Legion::Extensions::Bias::Helpers::Constants::CONFIRMATION_WEIGHT *
|
|
65
|
+
Legion::Extensions::Bias::Helpers::Constants::DEFAULT_SUSCEPTIBILITY
|
|
66
|
+
expect(mag).to be_within(0.001).of(expected)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
describe '#detect_availability' do
|
|
71
|
+
it 'returns higher magnitude with more recent events' do
|
|
72
|
+
mag_full = detector.detect_availability(recent_events: Array.new(10, :event))
|
|
73
|
+
mag_none = detector.detect_availability(recent_events: [])
|
|
74
|
+
expect(mag_full).to be > mag_none
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
it 'returns 0 with empty recent_events' do
|
|
78
|
+
mag = detector.detect_availability(recent_events: [])
|
|
79
|
+
expect(mag).to eq(0.0)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
describe '#detect_recency' do
|
|
84
|
+
it 'returns 0 with fewer than 2 data points' do
|
|
85
|
+
mag = detector.detect_recency(data_points: [1.0])
|
|
86
|
+
expect(mag).to eq(0.0)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
it 'returns 0 when all values are equal (zero range)' do
|
|
90
|
+
mag = detector.detect_recency(data_points: [5.0, 5.0, 5.0, 5.0])
|
|
91
|
+
expect(mag).to eq(0.0)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
it 'returns higher magnitude when recent half differs significantly from earlier half' do
|
|
95
|
+
early = [1.0, 1.0, 1.0, 1.0]
|
|
96
|
+
recent = [9.0, 9.0, 9.0, 9.0]
|
|
97
|
+
mag = detector.detect_recency(data_points: early + recent)
|
|
98
|
+
expect(mag).to be > 0.3
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
describe '#detect_sunk_cost' do
|
|
103
|
+
it 'returns 0 when invested is 0' do
|
|
104
|
+
mag = detector.detect_sunk_cost(invested: 0, expected_return: 100)
|
|
105
|
+
expect(mag).to eq(0.0)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
it 'returns higher magnitude with large investment and low expected return' do
|
|
109
|
+
mag = detector.detect_sunk_cost(invested: 1_000_000, expected_return: 1)
|
|
110
|
+
expect(mag).to be > 0.3
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
it 'returns lower magnitude with small investment and large expected return' do
|
|
114
|
+
mag = detector.detect_sunk_cost(invested: 1, expected_return: 1_000_000)
|
|
115
|
+
expect(mag).to be < 0.1
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
describe '#correction_for' do
|
|
120
|
+
it 'applies CORRECTION_FACTOR to magnitude' do
|
|
121
|
+
result = detector.correction_for(0.6)
|
|
122
|
+
expect(result).to be_within(0.001).of(
|
|
123
|
+
0.6 * Legion::Extensions::Bias::Helpers::Constants::CORRECTION_FACTOR
|
|
124
|
+
)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
it 'clamps to [0, 1]' do
|
|
128
|
+
expect(detector.correction_for(2.0)).to eq(1.0)
|
|
129
|
+
expect(detector.correction_for(-1.0)).to eq(0.0)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
describe '#update_susceptibility' do
|
|
134
|
+
it 'increases susceptibility toward 1.0 when repeatedly detected' do
|
|
135
|
+
initial = detector.susceptibility_for(:anchoring)
|
|
136
|
+
10.times { detector.update_susceptibility(:anchoring, detected: true) }
|
|
137
|
+
expect(detector.susceptibility_for(:anchoring)).to be > initial
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
it 'decreases susceptibility toward 0.0 when not detected' do
|
|
141
|
+
initial = detector.susceptibility_for(:anchoring)
|
|
142
|
+
10.times { detector.update_susceptibility(:anchoring, detected: false) }
|
|
143
|
+
expect(detector.susceptibility_for(:anchoring)).to be < initial
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
it 'ignores unknown bias types' do
|
|
147
|
+
expect { detector.update_susceptibility(:unknown_bias, detected: true) }.not_to raise_error
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
describe '#to_h' do
|
|
152
|
+
it 'returns susceptibility hash' do
|
|
153
|
+
h = detector.to_h
|
|
154
|
+
expect(h[:susceptibility]).to be_a(Hash)
|
|
155
|
+
expect(h[:susceptibility].keys).to match_array(
|
|
156
|
+
Legion::Extensions::Bias::Helpers::Constants::BIAS_TYPES
|
|
157
|
+
)
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/bias/helpers/bias_event'
|
|
4
|
+
|
|
5
|
+
RSpec.describe Legion::Extensions::Bias::Helpers::BiasEvent do
|
|
6
|
+
let(:event) do
|
|
7
|
+
described_class.new(
|
|
8
|
+
bias_type: :anchoring,
|
|
9
|
+
domain: :finance,
|
|
10
|
+
magnitude: 0.6,
|
|
11
|
+
corrected: true,
|
|
12
|
+
correction_applied: 0.3,
|
|
13
|
+
context: { current_value: 100 }
|
|
14
|
+
)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
describe '#initialize' do
|
|
18
|
+
it 'assigns a uuid id' do
|
|
19
|
+
expect(event.id).to match(/\A[0-9a-f-]{36}\z/)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
it 'assigns bias_type' do
|
|
23
|
+
expect(event.bias_type).to eq(:anchoring)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
it 'assigns domain' do
|
|
27
|
+
expect(event.domain).to eq(:finance)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
it 'assigns magnitude' do
|
|
31
|
+
expect(event.magnitude).to eq(0.6)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
it 'assigns corrected flag' do
|
|
35
|
+
expect(event.corrected).to be true
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
it 'assigns correction_applied' do
|
|
39
|
+
expect(event.correction_applied).to eq(0.3)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
it 'assigns context' do
|
|
43
|
+
expect(event.context).to eq({ current_value: 100 })
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
it 'sets timestamp to a Time' do
|
|
47
|
+
expect(event.timestamp).to be_a(Time)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
describe '#to_h' do
|
|
52
|
+
it 'returns a hash with all fields' do
|
|
53
|
+
h = event.to_h
|
|
54
|
+
expect(h[:id]).to eq(event.id)
|
|
55
|
+
expect(h[:bias_type]).to eq(:anchoring)
|
|
56
|
+
expect(h[:domain]).to eq(:finance)
|
|
57
|
+
expect(h[:magnitude]).to eq(0.6)
|
|
58
|
+
expect(h[:corrected]).to be true
|
|
59
|
+
expect(h[:correction_applied]).to eq(0.3)
|
|
60
|
+
expect(h[:context]).to eq({ current_value: 100 })
|
|
61
|
+
expect(h[:timestamp]).to be_a(Time)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/bias/helpers/constants'
|
|
4
|
+
require 'legion/extensions/bias/helpers/bias_event'
|
|
5
|
+
require 'legion/extensions/bias/helpers/bias_store'
|
|
6
|
+
|
|
7
|
+
RSpec.describe Legion::Extensions::Bias::Helpers::BiasStore do
|
|
8
|
+
subject(:store) { described_class.new }
|
|
9
|
+
|
|
10
|
+
let(:event) do
|
|
11
|
+
Legion::Extensions::Bias::Helpers::BiasEvent.new(
|
|
12
|
+
bias_type: :anchoring,
|
|
13
|
+
domain: :finance,
|
|
14
|
+
magnitude: 0.6
|
|
15
|
+
)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
let(:event2) do
|
|
19
|
+
Legion::Extensions::Bias::Helpers::BiasEvent.new(
|
|
20
|
+
bias_type: :confirmation,
|
|
21
|
+
domain: :research,
|
|
22
|
+
magnitude: 0.4
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
describe '#record' do
|
|
27
|
+
it 'stores an event and returns it' do
|
|
28
|
+
result = store.record(event)
|
|
29
|
+
expect(result).to eq(event)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
it 'trims events at MAX_BIAS_EVENTS' do
|
|
33
|
+
max = Legion::Extensions::Bias::Helpers::Constants::MAX_BIAS_EVENTS
|
|
34
|
+
(max + 10).times do
|
|
35
|
+
store.record(Legion::Extensions::Bias::Helpers::BiasEvent.new(
|
|
36
|
+
bias_type: :recency,
|
|
37
|
+
domain: :test,
|
|
38
|
+
magnitude: 0.1
|
|
39
|
+
))
|
|
40
|
+
end
|
|
41
|
+
expect(store.recent(max + 10).size).to eq(max)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
describe '#recent' do
|
|
46
|
+
it 'returns the most recent events as hashes' do
|
|
47
|
+
store.record(event)
|
|
48
|
+
store.record(event2)
|
|
49
|
+
result = store.recent(2)
|
|
50
|
+
expect(result.size).to eq(2)
|
|
51
|
+
expect(result.first).to be_a(Hash)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
it 'returns fewer than requested when not enough events' do
|
|
55
|
+
store.record(event)
|
|
56
|
+
expect(store.recent(10).size).to eq(1)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
describe '#by_type' do
|
|
61
|
+
it 'returns only events matching bias_type' do
|
|
62
|
+
store.record(event)
|
|
63
|
+
store.record(event2)
|
|
64
|
+
result = store.by_type(:anchoring)
|
|
65
|
+
expect(result.size).to eq(1)
|
|
66
|
+
expect(result.first[:bias_type]).to eq(:anchoring)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
describe '#by_domain' do
|
|
71
|
+
it 'returns only events matching domain' do
|
|
72
|
+
store.record(event)
|
|
73
|
+
store.record(event2)
|
|
74
|
+
result = store.by_domain(:finance)
|
|
75
|
+
expect(result.size).to eq(1)
|
|
76
|
+
expect(result.first[:domain]).to eq(:finance)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
describe '#register_anchor and #anchors_for' do
|
|
81
|
+
it 'stores anchor values for a domain' do
|
|
82
|
+
store.register_anchor(:pricing, value: 99.99)
|
|
83
|
+
anchors = store.anchors_for(:pricing)
|
|
84
|
+
expect(anchors.size).to eq(1)
|
|
85
|
+
expect(anchors.first[:value]).to eq(99.99)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
it 'returns empty array for unknown domain' do
|
|
89
|
+
expect(store.anchors_for(:nonexistent)).to eq([])
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
it 'trims anchors at MAX_ANCHORS' do
|
|
93
|
+
max = Legion::Extensions::Bias::Helpers::Constants::MAX_ANCHORS
|
|
94
|
+
(max + 5).times { |i| store.register_anchor(:domain, value: i) }
|
|
95
|
+
expect(store.anchors_for(:domain).size).to eq(max)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
describe '#decay_anchors' do
|
|
100
|
+
it 'reduces anchor influence' do
|
|
101
|
+
store.register_anchor(:pricing, value: 100.0, influence: 1.0)
|
|
102
|
+
store.decay_anchors
|
|
103
|
+
anchor = store.anchors_for(:pricing).first
|
|
104
|
+
expect(anchor[:influence]).to be < 1.0
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
it 'removes anchors with influence at or below 0' do
|
|
108
|
+
store.register_anchor(:pricing, value: 1.0, influence: 0.01)
|
|
109
|
+
store.decay_anchors
|
|
110
|
+
expect(store.anchors_for(:pricing)).to be_empty
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
describe '#stats' do
|
|
115
|
+
it 'returns total 0 with empty store' do
|
|
116
|
+
result = store.stats
|
|
117
|
+
expect(result[:total]).to eq(0)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
it 'aggregates counts and avg_magnitude per type' do
|
|
121
|
+
store.record(event)
|
|
122
|
+
stats = store.stats
|
|
123
|
+
expect(stats[:by_type][:anchoring][:count]).to eq(1)
|
|
124
|
+
expect(stats[:by_type][:anchoring][:avg_magnitude]).to eq(0.6)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
it 'aggregates by domain' do
|
|
128
|
+
store.record(event)
|
|
129
|
+
stats = store.stats
|
|
130
|
+
expect(stats[:by_domain][:finance][:count]).to eq(1)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
describe '#to_h' do
|
|
135
|
+
it 'returns total_events and anchor_domains' do
|
|
136
|
+
store.record(event)
|
|
137
|
+
store.register_anchor(:pricing, value: 50.0)
|
|
138
|
+
h = store.to_h
|
|
139
|
+
expect(h[:total_events]).to eq(1)
|
|
140
|
+
expect(h[:anchor_domains]).to include(:pricing)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/bias/client'
|
|
4
|
+
|
|
5
|
+
RSpec.describe Legion::Extensions::Bias::Runners::Bias do
|
|
6
|
+
let(:client) { Legion::Extensions::Bias::Client.new }
|
|
7
|
+
|
|
8
|
+
describe '#record_anchor' do
|
|
9
|
+
it 'returns success: true' do
|
|
10
|
+
result = client.record_anchor(domain: :finance, value: 100.0)
|
|
11
|
+
expect(result[:success]).to be true
|
|
12
|
+
expect(result[:domain]).to eq(:finance)
|
|
13
|
+
expect(result[:value]).to eq(100.0)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
describe '#check_for_bias' do
|
|
18
|
+
context 'with no decision context' do
|
|
19
|
+
it 'returns success: true with empty detected list' do
|
|
20
|
+
result = client.check_for_bias(domain: :test)
|
|
21
|
+
expect(result[:success]).to be true
|
|
22
|
+
expect(result[:detected]).to be_empty
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
context 'with anchoring context' do
|
|
27
|
+
before { client.record_anchor(domain: :finance, value: 100.0) }
|
|
28
|
+
|
|
29
|
+
it 'detects anchoring when current value is close to anchor' do
|
|
30
|
+
result = client.check_for_bias(
|
|
31
|
+
domain: :finance,
|
|
32
|
+
decision_context: { current_value: 101.0 }
|
|
33
|
+
)
|
|
34
|
+
expect(result[:success]).to be true
|
|
35
|
+
anchoring = result[:all].find { |b| b[:bias_type] == :anchoring }
|
|
36
|
+
expect(anchoring).not_to be_nil
|
|
37
|
+
expect(anchoring[:magnitude]).to be > 0.0
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
context 'with confirmation bias context' do
|
|
42
|
+
it 'detects confirmation bias when evidence matches hypothesis' do
|
|
43
|
+
result = client.check_for_bias(
|
|
44
|
+
domain: :research,
|
|
45
|
+
decision_context: {
|
|
46
|
+
evidence_direction: :positive,
|
|
47
|
+
hypothesis_direction: :positive
|
|
48
|
+
}
|
|
49
|
+
)
|
|
50
|
+
confirmation = result[:all].find { |b| b[:bias_type] == :confirmation }
|
|
51
|
+
expect(confirmation[:magnitude]).to be > 0.0
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
context 'with availability bias context' do
|
|
56
|
+
it 'detects availability bias with recent events' do
|
|
57
|
+
result = client.check_for_bias(
|
|
58
|
+
domain: :safety,
|
|
59
|
+
decision_context: { recent_events: Array.new(10, :incident) }
|
|
60
|
+
)
|
|
61
|
+
availability = result[:all].find { |b| b[:bias_type] == :availability }
|
|
62
|
+
expect(availability[:magnitude]).to be > 0.0
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
context 'with recency bias context' do
|
|
67
|
+
it 'detects recency bias with skewed data points' do
|
|
68
|
+
data = [1.0, 1.0, 1.0, 1.0, 9.0, 9.0, 9.0, 9.0]
|
|
69
|
+
result = client.check_for_bias(
|
|
70
|
+
domain: :market,
|
|
71
|
+
decision_context: { data_points: data }
|
|
72
|
+
)
|
|
73
|
+
recency = result[:all].find { |b| b[:bias_type] == :recency }
|
|
74
|
+
expect(recency[:magnitude]).to be > 0.0
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
context 'with sunk cost bias context' do
|
|
79
|
+
it 'detects sunk cost bias with high investment and low return' do
|
|
80
|
+
result = client.check_for_bias(
|
|
81
|
+
domain: :project,
|
|
82
|
+
decision_context: { invested: 1_000_000, expected_return: 100 }
|
|
83
|
+
)
|
|
84
|
+
sunk = result[:all].find { |b| b[:bias_type] == :sunk_cost }
|
|
85
|
+
expect(sunk[:magnitude]).to be > 0.0
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
it 'marks bias as corrected when magnitude exceeds threshold' do
|
|
90
|
+
client.record_anchor(domain: :finance, value: 100.0)
|
|
91
|
+
result = client.check_for_bias(
|
|
92
|
+
domain: :finance,
|
|
93
|
+
decision_context: { current_value: 100.0 }
|
|
94
|
+
)
|
|
95
|
+
anchoring = result[:all].find { |b| b[:bias_type] == :anchoring }
|
|
96
|
+
if anchoring[:magnitude] >= Legion::Extensions::Bias::Helpers::Constants::DETECTION_THRESHOLD
|
|
97
|
+
expect(anchoring[:corrected]).to be true
|
|
98
|
+
expect(anchoring[:correction_applied]).to be > 0.0
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
describe '#update_bias' do
|
|
104
|
+
it 'returns success: true' do
|
|
105
|
+
result = client.update_bias
|
|
106
|
+
expect(result[:success]).to be true
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
it 'decays anchor influence' do
|
|
110
|
+
client.record_anchor(domain: :pricing, value: 50.0)
|
|
111
|
+
client.update_bias
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
describe '#bias_report' do
|
|
116
|
+
it 'returns success: true with empty events on fresh client' do
|
|
117
|
+
result = client.bias_report
|
|
118
|
+
expect(result[:success]).to be true
|
|
119
|
+
expect(result[:count]).to eq(0)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
it 'filters by domain when provided' do
|
|
123
|
+
client.record_anchor(domain: :finance, value: 100.0)
|
|
124
|
+
client.check_for_bias(domain: :finance, decision_context: { current_value: 100.0 })
|
|
125
|
+
result = client.bias_report(domain: :finance)
|
|
126
|
+
expect(result[:domain]).to eq(:finance)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
describe '#susceptibility_profile' do
|
|
131
|
+
it 'returns success: true with susceptibility hash' do
|
|
132
|
+
result = client.susceptibility_profile
|
|
133
|
+
expect(result[:success]).to be true
|
|
134
|
+
expect(result[:susceptibility]).to be_a(Hash)
|
|
135
|
+
expect(result[:susceptibility].keys).to match_array(
|
|
136
|
+
Legion::Extensions::Bias::Helpers::Constants::BIAS_TYPES
|
|
137
|
+
)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
describe '#bias_stats' do
|
|
142
|
+
it 'returns success: true' do
|
|
143
|
+
result = client.bias_stats
|
|
144
|
+
expect(result[:success]).to be true
|
|
145
|
+
expect(result[:total]).to eq(0)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
it 'reflects recorded events' do
|
|
149
|
+
client.record_anchor(domain: :finance, value: 100.0)
|
|
150
|
+
client.check_for_bias(domain: :finance, decision_context: { current_value: 100.0 })
|
|
151
|
+
result = client.bias_stats
|
|
152
|
+
expect(result[:total]).to be >= 0
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
data/spec/spec_helper.rb
ADDED
|
@@ -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/bias/client'
|
|
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-bias
|
|
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: Cognitive bias detection and correction 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
|
+
- lex-bias.gemspec
|
|
36
|
+
- lib/legion/extensions/bias.rb
|
|
37
|
+
- lib/legion/extensions/bias/actors/update.rb
|
|
38
|
+
- lib/legion/extensions/bias/client.rb
|
|
39
|
+
- lib/legion/extensions/bias/helpers/bias_detector.rb
|
|
40
|
+
- lib/legion/extensions/bias/helpers/bias_event.rb
|
|
41
|
+
- lib/legion/extensions/bias/helpers/bias_store.rb
|
|
42
|
+
- lib/legion/extensions/bias/helpers/constants.rb
|
|
43
|
+
- lib/legion/extensions/bias/runners/bias.rb
|
|
44
|
+
- lib/legion/extensions/bias/version.rb
|
|
45
|
+
- spec/legion/extensions/bias/client_spec.rb
|
|
46
|
+
- spec/legion/extensions/bias/helpers/bias_detector_spec.rb
|
|
47
|
+
- spec/legion/extensions/bias/helpers/bias_event_spec.rb
|
|
48
|
+
- spec/legion/extensions/bias/helpers/bias_store_spec.rb
|
|
49
|
+
- spec/legion/extensions/bias/runners/bias_spec.rb
|
|
50
|
+
- spec/spec_helper.rb
|
|
51
|
+
homepage: https://github.com/LegionIO/lex-bias
|
|
52
|
+
licenses:
|
|
53
|
+
- MIT
|
|
54
|
+
metadata:
|
|
55
|
+
homepage_uri: https://github.com/LegionIO/lex-bias
|
|
56
|
+
source_code_uri: https://github.com/LegionIO/lex-bias
|
|
57
|
+
documentation_uri: https://github.com/LegionIO/lex-bias
|
|
58
|
+
changelog_uri: https://github.com/LegionIO/lex-bias
|
|
59
|
+
bug_tracker_uri: https://github.com/LegionIO/lex-bias/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 Bias
|
|
78
|
+
test_files: []
|