lex-anchoring 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/README.md +58 -0
- data/lex-anchoring.gemspec +29 -0
- data/lib/legion/extensions/anchoring/client.rb +22 -0
- data/lib/legion/extensions/anchoring/helpers/anchor.rb +61 -0
- data/lib/legion/extensions/anchoring/helpers/anchor_store.rb +128 -0
- data/lib/legion/extensions/anchoring/helpers/constants.rb +27 -0
- data/lib/legion/extensions/anchoring/runners/anchoring.rb +96 -0
- data/lib/legion/extensions/anchoring/version.rb +9 -0
- data/lib/legion/extensions/anchoring.rb +15 -0
- data/spec/legion/extensions/anchoring/client_spec.rb +32 -0
- data/spec/legion/extensions/anchoring/helpers/anchor_spec.rb +130 -0
- data/spec/legion/extensions/anchoring/helpers/anchor_store_spec.rb +201 -0
- data/spec/legion/extensions/anchoring/helpers/constants_spec.rb +63 -0
- data/spec/legion/extensions/anchoring/runners/anchoring_spec.rb +199 -0
- data/spec/spec_helper.rb +27 -0
- metadata +78 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 69728f9f687e438684ae064c6d9e4af0143f0c045180b24741fd6411fac09e8f
|
|
4
|
+
data.tar.gz: 608e3c7a14e1152a712a7071f8cd4ea5fcc0d03b2ed7b3f084b83166cf701b10
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 127782ae3c0c6a546af57e851cb3c70bcf65c222b489066cd85167618dca05eb978988624e3e374c0c765b648b906bcc3be5a0949914cc849b4b1f7a47737682
|
|
7
|
+
data.tar.gz: 647131ea6bc0ff9c341b2156ee5cd102009a83718991b92397aa074f17350b9422630069bc80f01b8b03e32f20c0416a657b9bb6d7d24be6ac0034f00b2b2301
|
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/README.md
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# lex-anchoring
|
|
2
|
+
|
|
3
|
+
Decision anchoring and reference point effects for brain-modeled agentic AI.
|
|
4
|
+
|
|
5
|
+
## What It Does
|
|
6
|
+
|
|
7
|
+
Models the cognitive anchoring bias: initial values exert disproportionate gravitational pull on subsequent estimates. Implements both anchoring (estimates are pulled toward initial values) and prospect theory's reference point framing (outcomes are perceived as gains or losses relative to a reference, with losses weighted 2.25x more than equivalent gains).
|
|
8
|
+
|
|
9
|
+
## Core Concept: Anchor Pull
|
|
10
|
+
|
|
11
|
+
An anchor exerts a pull on estimates proportional to its strength:
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
# anchored_estimate = (strength * DEFAULT_ANCHOR_WEIGHT * anchor_value) +
|
|
15
|
+
# ((1 - strength * DEFAULT_ANCHOR_WEIGHT) * raw_estimate)
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
With default weight 0.6 and full strength, a strong anchor pulls 60% toward itself.
|
|
19
|
+
|
|
20
|
+
## Usage
|
|
21
|
+
|
|
22
|
+
```ruby
|
|
23
|
+
client = Legion::Extensions::Anchoring::Client.new
|
|
24
|
+
|
|
25
|
+
# Record an anchor (e.g., initial budget estimate)
|
|
26
|
+
client.record_anchor(value: 100_000.0, domain: :budget)
|
|
27
|
+
|
|
28
|
+
# Evaluate a new estimate — it will be pulled toward the anchor
|
|
29
|
+
result = client.evaluate_estimate(estimate: 150_000.0, domain: :budget)
|
|
30
|
+
# => { anchored_estimate: 130_000.0, pull_strength: 0.6, correction: 20_000.0 }
|
|
31
|
+
|
|
32
|
+
# Frame a value as gain or loss (with 2.25x loss aversion)
|
|
33
|
+
client.reference_frame(value: 80_000.0, domain: :budget)
|
|
34
|
+
# => { gain_or_loss: :loss, magnitude: 45_000.0, reference: 100_000.0 }
|
|
35
|
+
|
|
36
|
+
# Remove the bias to get the corrected estimate
|
|
37
|
+
client.de_anchor(estimate: 130_000.0, domain: :budget)
|
|
38
|
+
# => { corrected_estimate: 110_000.0, anchor_bias: 20_000.0 }
|
|
39
|
+
|
|
40
|
+
# Maintenance
|
|
41
|
+
client.update_anchoring
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Integration
|
|
45
|
+
|
|
46
|
+
Pairs with lex-bias for comprehensive bias detection. `reference_frame` output feeds naturally into lex-emotion for emotional gain/loss responses. Wire into decision phases where the agent evaluates numerical estimates or compares outcomes against baselines.
|
|
47
|
+
|
|
48
|
+
## Development
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
bundle install
|
|
52
|
+
bundle exec rspec
|
|
53
|
+
bundle exec rubocop
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## License
|
|
57
|
+
|
|
58
|
+
MIT
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'lib/legion/extensions/anchoring/version'
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = 'lex-anchoring'
|
|
7
|
+
spec.version = Legion::Extensions::Anchoring::VERSION
|
|
8
|
+
spec.authors = ['Esity']
|
|
9
|
+
spec.email = ['matthewdiverson@gmail.com']
|
|
10
|
+
|
|
11
|
+
spec.summary = 'LEX Anchoring'
|
|
12
|
+
spec.description = 'Decision anchoring and reference point effects for brain-modeled agentic AI'
|
|
13
|
+
spec.homepage = 'https://github.com/LegionIO/lex-anchoring'
|
|
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-anchoring'
|
|
19
|
+
spec.metadata['documentation_uri'] = 'https://github.com/LegionIO/lex-anchoring'
|
|
20
|
+
spec.metadata['changelog_uri'] = 'https://github.com/LegionIO/lex-anchoring'
|
|
21
|
+
spec.metadata['bug_tracker_uri'] = 'https://github.com/LegionIO/lex-anchoring/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-anchoring.gemspec Gemfile LICENSE README.md]
|
|
26
|
+
end
|
|
27
|
+
spec.require_paths = ['lib']
|
|
28
|
+
spec.add_development_dependency 'legion-gaia'
|
|
29
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/anchoring/helpers/constants'
|
|
4
|
+
require 'legion/extensions/anchoring/helpers/anchor'
|
|
5
|
+
require 'legion/extensions/anchoring/helpers/anchor_store'
|
|
6
|
+
require 'legion/extensions/anchoring/runners/anchoring'
|
|
7
|
+
|
|
8
|
+
module Legion
|
|
9
|
+
module Extensions
|
|
10
|
+
module Anchoring
|
|
11
|
+
class Client
|
|
12
|
+
include Runners::Anchoring
|
|
13
|
+
|
|
14
|
+
attr_reader :anchor_store
|
|
15
|
+
|
|
16
|
+
def initialize(anchor_store: nil, **)
|
|
17
|
+
@anchor_store = anchor_store || Helpers::AnchorStore.new
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module Extensions
|
|
7
|
+
module Anchoring
|
|
8
|
+
module Helpers
|
|
9
|
+
class Anchor
|
|
10
|
+
include Constants
|
|
11
|
+
|
|
12
|
+
attr_reader :id, :value, :domain, :strength, :created_at, :last_accessed
|
|
13
|
+
|
|
14
|
+
def initialize(value:, domain: :general)
|
|
15
|
+
@id = SecureRandom.uuid
|
|
16
|
+
@value = value.to_f
|
|
17
|
+
@domain = domain.to_sym
|
|
18
|
+
@strength = 1.0
|
|
19
|
+
@created_at = Time.now.utc
|
|
20
|
+
@last_accessed = Time.now.utc
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def decay
|
|
24
|
+
@strength = [(@strength - Constants::ANCHOR_DECAY), 0.0].max
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def reinforce(observed_value:)
|
|
28
|
+
@last_accessed = Time.now.utc
|
|
29
|
+
alpha = Constants::ADJUSTMENT_RATE
|
|
30
|
+
@value = ((1.0 - alpha) * @value) + (alpha * observed_value.to_f)
|
|
31
|
+
@strength = [@strength + 0.1, 1.0].min
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def pull(estimate:)
|
|
35
|
+
weight = @strength * Constants::DEFAULT_ANCHOR_WEIGHT
|
|
36
|
+
(weight * @value) + ((1.0 - weight) * estimate.to_f)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def label
|
|
40
|
+
Constants::ANCHOR_LABELS.each do |range, lbl|
|
|
41
|
+
return lbl if range.cover?(@strength)
|
|
42
|
+
end
|
|
43
|
+
:fading
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def to_h
|
|
47
|
+
{
|
|
48
|
+
id: @id,
|
|
49
|
+
value: @value,
|
|
50
|
+
domain: @domain,
|
|
51
|
+
strength: @strength,
|
|
52
|
+
label: label,
|
|
53
|
+
created_at: @created_at,
|
|
54
|
+
last_accessed: @last_accessed
|
|
55
|
+
}
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Anchoring
|
|
6
|
+
module Helpers
|
|
7
|
+
class AnchorStore
|
|
8
|
+
include Constants
|
|
9
|
+
|
|
10
|
+
def initialize
|
|
11
|
+
@anchors = {} # domain (Symbol) -> Array<Anchor>
|
|
12
|
+
@references = {} # domain (Symbol) -> Float (explicit reference point)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def add(value:, domain: :general)
|
|
16
|
+
domain = domain.to_sym
|
|
17
|
+
@anchors[domain] ||= []
|
|
18
|
+
|
|
19
|
+
anchor = Anchor.new(value: value, domain: domain)
|
|
20
|
+
@anchors[domain] << anchor
|
|
21
|
+
|
|
22
|
+
if @anchors[domain].size > Constants::MAX_ANCHORS_PER_DOMAIN
|
|
23
|
+
@anchors[domain] = @anchors[domain].sort_by(&:strength).last(Constants::MAX_ANCHORS_PER_DOMAIN)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
prune_domains
|
|
27
|
+
anchor
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def strongest(domain: :general)
|
|
31
|
+
domain = domain.to_sym
|
|
32
|
+
anchors = @anchors[domain]
|
|
33
|
+
return nil if anchors.nil? || anchors.empty?
|
|
34
|
+
|
|
35
|
+
anchors.max_by(&:strength)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def find(id:)
|
|
39
|
+
@anchors.each_value do |arr|
|
|
40
|
+
arr.each { |a| return a if a.id == id }
|
|
41
|
+
end
|
|
42
|
+
nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def evaluate(estimate:, domain: :general)
|
|
46
|
+
domain = domain.to_sym
|
|
47
|
+
anchor = strongest(domain: domain)
|
|
48
|
+
return { anchored_estimate: estimate.to_f, pull_strength: 0.0, anchor_value: nil, correction: 0.0 } if anchor.nil?
|
|
49
|
+
|
|
50
|
+
anchored = anchor.pull(estimate: estimate)
|
|
51
|
+
pull_str = anchor.strength * Constants::DEFAULT_ANCHOR_WEIGHT
|
|
52
|
+
correction = estimate.to_f - anchored
|
|
53
|
+
|
|
54
|
+
anchor.reinforce(observed_value: estimate)
|
|
55
|
+
|
|
56
|
+
{
|
|
57
|
+
anchored_estimate: anchored,
|
|
58
|
+
pull_strength: pull_str,
|
|
59
|
+
anchor_value: anchor.value,
|
|
60
|
+
correction: correction
|
|
61
|
+
}
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def reference_frame(value:, domain: :general)
|
|
65
|
+
domain = domain.to_sym
|
|
66
|
+
reference = @references[domain] || strongest(domain: domain)&.value
|
|
67
|
+
return { perceived_value: value.to_f, gain_or_loss: :neutral, magnitude: 0.0 } if reference.nil?
|
|
68
|
+
|
|
69
|
+
diff = value.to_f - reference
|
|
70
|
+
gain_loss = diff >= 0 ? :gain : :loss
|
|
71
|
+
raw_mag = diff.abs
|
|
72
|
+
magnitude = gain_loss == :loss ? raw_mag * Constants::LOSS_AVERSION_FACTOR : raw_mag
|
|
73
|
+
|
|
74
|
+
{ perceived_value: value.to_f, gain_or_loss: gain_loss, magnitude: magnitude, reference: reference, raw_diff: diff }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def decay_all
|
|
78
|
+
pruned = 0
|
|
79
|
+
@anchors.each_value do |arr|
|
|
80
|
+
arr.each(&:decay)
|
|
81
|
+
before = arr.size
|
|
82
|
+
arr.reject! { |a| a.strength < Constants::ANCHOR_FLOOR }
|
|
83
|
+
pruned += (before - arr.size)
|
|
84
|
+
end
|
|
85
|
+
@anchors.reject! { |_, arr| arr.empty? }
|
|
86
|
+
pruned
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def shift_reference(domain:, new_reference:)
|
|
90
|
+
domain = domain.to_sym
|
|
91
|
+
old = @references[domain]
|
|
92
|
+
@references[domain] = new_reference.to_f
|
|
93
|
+
|
|
94
|
+
diff = (new_reference.to_f - old.to_f).abs
|
|
95
|
+
significant = diff >= Constants::REFERENCE_SHIFT_THRESHOLD
|
|
96
|
+
|
|
97
|
+
{ domain: domain, old_reference: old, new_reference: new_reference.to_f, significant: significant }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def domains
|
|
101
|
+
@anchors.keys.select { |d| @anchors[d]&.any? }
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def to_h
|
|
105
|
+
total_anchors = @anchors.values.flatten.size
|
|
106
|
+
domain_count = domains.size
|
|
107
|
+
|
|
108
|
+
{
|
|
109
|
+
total_anchors: total_anchors,
|
|
110
|
+
domain_count: domain_count,
|
|
111
|
+
domains: domains,
|
|
112
|
+
references: @references.dup
|
|
113
|
+
}
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
private
|
|
117
|
+
|
|
118
|
+
def prune_domains
|
|
119
|
+
return unless @anchors.size > Constants::MAX_DOMAINS
|
|
120
|
+
|
|
121
|
+
oldest_domain = @anchors.min_by { |_, arr| arr.map(&:last_accessed).min }&.first
|
|
122
|
+
@anchors.delete(oldest_domain) if oldest_domain
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Anchoring
|
|
6
|
+
module Helpers
|
|
7
|
+
module Constants
|
|
8
|
+
DEFAULT_ANCHOR_WEIGHT = 0.6 # How much the anchor pulls toward itself
|
|
9
|
+
ADJUSTMENT_RATE = 0.1 # EMA alpha for updating anchors
|
|
10
|
+
ANCHOR_DECAY = 0.02 # Per-tick decay of anchor strength
|
|
11
|
+
ANCHOR_FLOOR = 0.05 # Minimum anchor strength before pruning
|
|
12
|
+
MAX_ANCHORS_PER_DOMAIN = 20 # Cap per domain
|
|
13
|
+
MAX_DOMAINS = 50 # Cap total domains
|
|
14
|
+
REFERENCE_SHIFT_THRESHOLD = 0.3 # Gap needed to shift reference point
|
|
15
|
+
LOSS_AVERSION_FACTOR = 2.25 # Losses weighted 2.25x vs gains (prospect theory)
|
|
16
|
+
|
|
17
|
+
ANCHOR_LABELS = {
|
|
18
|
+
(0.8..) => :strong,
|
|
19
|
+
(0.5...0.8) => :moderate,
|
|
20
|
+
(0.2...0.5) => :weak,
|
|
21
|
+
(..0.2) => :fading
|
|
22
|
+
}.freeze
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Anchoring
|
|
6
|
+
module Runners
|
|
7
|
+
module Anchoring
|
|
8
|
+
include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers) &&
|
|
9
|
+
Legion::Extensions::Helpers.const_defined?(:Lex)
|
|
10
|
+
|
|
11
|
+
def record_anchor(value:, domain: :general, **)
|
|
12
|
+
anchor = anchor_store.add(value: value, domain: domain)
|
|
13
|
+
Legion::Logging.debug "[anchoring] record_anchor domain=#{domain} value=#{value} id=#{anchor.id}"
|
|
14
|
+
{ success: true, anchor: anchor.to_h }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def evaluate_estimate(estimate:, domain: :general, **)
|
|
18
|
+
result = anchor_store.evaluate(estimate: estimate, domain: domain)
|
|
19
|
+
Legion::Logging.debug "[anchoring] evaluate_estimate domain=#{domain} estimate=#{estimate} " \
|
|
20
|
+
"anchored=#{result[:anchored_estimate].round(4)} pull=#{result[:pull_strength].round(4)}"
|
|
21
|
+
{ success: true }.merge(result)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def reference_frame(value:, domain: :general, **)
|
|
25
|
+
result = anchor_store.reference_frame(value: value, domain: domain)
|
|
26
|
+
Legion::Logging.debug "[anchoring] reference_frame domain=#{domain} value=#{value} " \
|
|
27
|
+
"gain_or_loss=#{result[:gain_or_loss]}"
|
|
28
|
+
{ success: true }.merge(result)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def de_anchor(estimate:, domain: :general, **)
|
|
32
|
+
anchor = anchor_store.strongest(domain: domain)
|
|
33
|
+
if anchor.nil?
|
|
34
|
+
Legion::Logging.debug "[anchoring] de_anchor domain=#{domain} no anchor found"
|
|
35
|
+
return { success: true, corrected_estimate: estimate.to_f, anchor_bias: 0.0, domain: domain }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
biased = anchor.pull(estimate: estimate)
|
|
39
|
+
bias = biased - estimate.to_f
|
|
40
|
+
corrected = estimate.to_f - bias
|
|
41
|
+
|
|
42
|
+
Legion::Logging.debug "[anchoring] de_anchor domain=#{domain} estimate=#{estimate} " \
|
|
43
|
+
"corrected=#{corrected.round(4)} bias=#{bias.round(4)}"
|
|
44
|
+
|
|
45
|
+
{
|
|
46
|
+
success: true,
|
|
47
|
+
corrected_estimate: corrected,
|
|
48
|
+
original_estimate: estimate.to_f,
|
|
49
|
+
anchor_bias: bias,
|
|
50
|
+
anchor_value: anchor.value,
|
|
51
|
+
domain: domain
|
|
52
|
+
}
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def shift_reference(domain:, new_reference:, **)
|
|
56
|
+
result = anchor_store.shift_reference(domain: domain, new_reference: new_reference)
|
|
57
|
+
Legion::Logging.info "[anchoring] shift_reference domain=#{domain} new=#{new_reference} significant=#{result[:significant]}"
|
|
58
|
+
{ success: true }.merge(result)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def update_anchoring(**)
|
|
62
|
+
pruned = anchor_store.decay_all
|
|
63
|
+
Legion::Logging.debug "[anchoring] update_anchoring pruned=#{pruned}"
|
|
64
|
+
{ success: true, pruned: pruned }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def domain_anchors(domain:, **)
|
|
68
|
+
domain = domain.to_sym
|
|
69
|
+
anchor = anchor_store.strongest(domain: domain)
|
|
70
|
+
all_list = anchor_store.instance_variable_get(:@anchors)[domain] || []
|
|
71
|
+
Legion::Logging.debug "[anchoring] domain_anchors domain=#{domain} count=#{all_list.size}"
|
|
72
|
+
{
|
|
73
|
+
success: true,
|
|
74
|
+
domain: domain,
|
|
75
|
+
count: all_list.size,
|
|
76
|
+
strongest: anchor&.to_h,
|
|
77
|
+
anchors: all_list.map(&:to_h)
|
|
78
|
+
}
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def anchoring_stats(**)
|
|
82
|
+
stats = anchor_store.to_h
|
|
83
|
+
Legion::Logging.debug "[anchoring] stats domains=#{stats[:domain_count]} total=#{stats[:total_anchors]}"
|
|
84
|
+
{ success: true }.merge(stats)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def anchor_store
|
|
90
|
+
@anchor_store ||= Helpers::AnchorStore.new
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/anchoring/version'
|
|
4
|
+
require 'legion/extensions/anchoring/helpers/constants'
|
|
5
|
+
require 'legion/extensions/anchoring/helpers/anchor'
|
|
6
|
+
require 'legion/extensions/anchoring/helpers/anchor_store'
|
|
7
|
+
require 'legion/extensions/anchoring/runners/anchoring'
|
|
8
|
+
|
|
9
|
+
module Legion
|
|
10
|
+
module Extensions
|
|
11
|
+
module Anchoring
|
|
12
|
+
extend Legion::Extensions::Core if Legion::Extensions.const_defined? :Core
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/anchoring/client'
|
|
4
|
+
|
|
5
|
+
RSpec.describe Legion::Extensions::Anchoring::Client do
|
|
6
|
+
let(:client) { described_class.new }
|
|
7
|
+
|
|
8
|
+
it 'responds to all runner methods' do
|
|
9
|
+
expect(client).to respond_to(:record_anchor)
|
|
10
|
+
expect(client).to respond_to(:evaluate_estimate)
|
|
11
|
+
expect(client).to respond_to(:reference_frame)
|
|
12
|
+
expect(client).to respond_to(:de_anchor)
|
|
13
|
+
expect(client).to respond_to(:shift_reference)
|
|
14
|
+
expect(client).to respond_to(:update_anchoring)
|
|
15
|
+
expect(client).to respond_to(:domain_anchors)
|
|
16
|
+
expect(client).to respond_to(:anchoring_stats)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
it 'exposes anchor_store' do
|
|
20
|
+
expect(client.anchor_store).to be_a(Legion::Extensions::Anchoring::Helpers::AnchorStore)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
it 'accepts an external anchor_store' do
|
|
24
|
+
custom_store = Legion::Extensions::Anchoring::Helpers::AnchorStore.new
|
|
25
|
+
c = described_class.new(anchor_store: custom_store)
|
|
26
|
+
expect(c.anchor_store).to be(custom_store)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
it 'accepts keyword splat arguments' do
|
|
30
|
+
expect { described_class.new(extra: :ignored) }.not_to raise_error
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe Legion::Extensions::Anchoring::Helpers::Anchor do
|
|
4
|
+
subject(:anchor) { described_class.new(value: 100.0, domain: :financial) }
|
|
5
|
+
|
|
6
|
+
describe '#initialize' do
|
|
7
|
+
it 'sets value as float' do
|
|
8
|
+
expect(anchor.value).to eq(100.0)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
it 'sets domain as symbol' do
|
|
12
|
+
expect(anchor.domain).to eq(:financial)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
it 'sets initial strength to 1.0' do
|
|
16
|
+
expect(anchor.strength).to eq(1.0)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
it 'generates a uuid id' do
|
|
20
|
+
expect(anchor.id).to match(/\A[0-9a-f-]{36}\z/)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
it 'sets created_at' do
|
|
24
|
+
expect(anchor.created_at).to be_a(Time)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
it 'sets last_accessed' do
|
|
28
|
+
expect(anchor.last_accessed).to be_a(Time)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
it 'accepts integer value and converts to float' do
|
|
32
|
+
a = described_class.new(value: 50, domain: :general)
|
|
33
|
+
expect(a.value).to eq(50.0)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
it 'defaults domain to :general when not specified' do
|
|
37
|
+
a = described_class.new(value: 10.0)
|
|
38
|
+
expect(a.domain).to eq(:general)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
describe '#decay' do
|
|
43
|
+
it 'reduces strength by ANCHOR_DECAY' do
|
|
44
|
+
before = anchor.strength
|
|
45
|
+
anchor.decay
|
|
46
|
+
expect(anchor.strength).to be_within(0.001).of(before - Legion::Extensions::Anchoring::Helpers::Constants::ANCHOR_DECAY)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
it 'does not go below 0.0' do
|
|
50
|
+
50.times { anchor.decay }
|
|
51
|
+
expect(anchor.strength).to eq(0.0)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
describe '#reinforce' do
|
|
56
|
+
it 'updates the value toward observed_value via EMA' do
|
|
57
|
+
anchor.reinforce(observed_value: 200.0)
|
|
58
|
+
expect(anchor.value).to be > 100.0
|
|
59
|
+
expect(anchor.value).to be < 200.0
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
it 'bumps strength up (capped at 1.0)' do
|
|
63
|
+
anchor.decay
|
|
64
|
+
before = anchor.strength
|
|
65
|
+
anchor.reinforce(observed_value: 100.0)
|
|
66
|
+
expect(anchor.strength).to be > before
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
it 'updates last_accessed' do
|
|
70
|
+
before = anchor.last_accessed
|
|
71
|
+
sleep 0.001
|
|
72
|
+
anchor.reinforce(observed_value: 100.0)
|
|
73
|
+
expect(anchor.last_accessed).to be >= before
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
describe '#pull' do
|
|
78
|
+
it 'returns a value between anchor value and estimate' do
|
|
79
|
+
result = anchor.pull(estimate: 200.0)
|
|
80
|
+
expect(result).to be > 100.0
|
|
81
|
+
expect(result).to be < 200.0
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
it 'pulls estimate closer to anchor value' do
|
|
85
|
+
biased = anchor.pull(estimate: 200.0)
|
|
86
|
+
expect((biased - 100.0).abs).to be < (200.0 - 100.0).abs
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
it 'returns anchor value when estimate equals anchor value' do
|
|
90
|
+
result = anchor.pull(estimate: 100.0)
|
|
91
|
+
expect(result).to be_within(0.001).of(100.0)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
describe '#label' do
|
|
96
|
+
it 'returns :strong at full strength' do
|
|
97
|
+
expect(anchor.label).to eq(:strong)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
it 'returns :moderate at moderate strength' do
|
|
101
|
+
anchor.instance_variable_set(:@strength, 0.6)
|
|
102
|
+
expect(anchor.label).to eq(:moderate)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
it 'returns :weak at weak strength' do
|
|
106
|
+
anchor.instance_variable_set(:@strength, 0.35)
|
|
107
|
+
expect(anchor.label).to eq(:weak)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
it 'returns :fading at very low strength' do
|
|
111
|
+
anchor.instance_variable_set(:@strength, 0.1)
|
|
112
|
+
expect(anchor.label).to eq(:fading)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
describe '#to_h' do
|
|
117
|
+
it 'returns a hash with all expected keys' do
|
|
118
|
+
h = anchor.to_h
|
|
119
|
+
expect(h).to include(:id, :value, :domain, :strength, :label, :created_at, :last_accessed)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
it 'includes correct value' do
|
|
123
|
+
expect(anchor.to_h[:value]).to eq(100.0)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
it 'includes label' do
|
|
127
|
+
expect(anchor.to_h[:label]).to eq(:strong)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe Legion::Extensions::Anchoring::Helpers::AnchorStore do
|
|
4
|
+
subject(:store) { described_class.new }
|
|
5
|
+
|
|
6
|
+
describe '#add' do
|
|
7
|
+
it 'creates and returns an Anchor' do
|
|
8
|
+
anchor = store.add(value: 50.0, domain: :financial)
|
|
9
|
+
expect(anchor).to be_a(Legion::Extensions::Anchoring::Helpers::Anchor)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
it 'stores anchors per domain' do
|
|
13
|
+
store.add(value: 50.0, domain: :financial)
|
|
14
|
+
store.add(value: 80.0, domain: :financial)
|
|
15
|
+
store.add(value: 10.0, domain: :temporal)
|
|
16
|
+
expect(store.domains).to include(:financial, :temporal)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
it 'enforces MAX_ANCHORS_PER_DOMAIN limit' do
|
|
20
|
+
max = Legion::Extensions::Anchoring::Helpers::Constants::MAX_ANCHORS_PER_DOMAIN
|
|
21
|
+
(max + 5).times { |i| store.add(value: i.to_f, domain: :financial) }
|
|
22
|
+
all = store.instance_variable_get(:@anchors)[:financial]
|
|
23
|
+
expect(all.size).to be <= max
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
it 'accepts integer value' do
|
|
27
|
+
anchor = store.add(value: 100, domain: :general)
|
|
28
|
+
expect(anchor.value).to eq(100.0)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
describe '#strongest' do
|
|
33
|
+
it 'returns nil for unknown domain' do
|
|
34
|
+
expect(store.strongest(domain: :unknown)).to be_nil
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
it 'returns the anchor with highest strength' do
|
|
38
|
+
a1 = store.add(value: 10.0, domain: :financial)
|
|
39
|
+
a2 = store.add(value: 20.0, domain: :financial)
|
|
40
|
+
a1.instance_variable_set(:@strength, 0.3)
|
|
41
|
+
a2.instance_variable_set(:@strength, 0.9)
|
|
42
|
+
expect(store.strongest(domain: :financial).value).to eq(20.0)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
describe '#find' do
|
|
47
|
+
it 'finds anchor by id' do
|
|
48
|
+
anchor = store.add(value: 42.0, domain: :general)
|
|
49
|
+
found = store.find(id: anchor.id)
|
|
50
|
+
expect(found).to eq(anchor)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
it 'returns nil for unknown id' do
|
|
54
|
+
expect(store.find(id: 'nonexistent')).to be_nil
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
describe '#evaluate' do
|
|
59
|
+
it 'returns no-pull result when domain has no anchors' do
|
|
60
|
+
result = store.evaluate(estimate: 100.0, domain: :empty)
|
|
61
|
+
expect(result[:pull_strength]).to eq(0.0)
|
|
62
|
+
expect(result[:anchored_estimate]).to eq(100.0)
|
|
63
|
+
expect(result[:anchor_value]).to be_nil
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
it 'returns biased estimate when anchor exists' do
|
|
67
|
+
store.add(value: 50.0, domain: :financial)
|
|
68
|
+
result = store.evaluate(estimate: 100.0, domain: :financial)
|
|
69
|
+
expect(result[:anchored_estimate]).to be > 50.0
|
|
70
|
+
expect(result[:anchored_estimate]).to be < 100.0
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
it 'includes pull_strength, anchor_value, correction keys' do
|
|
74
|
+
store.add(value: 50.0, domain: :financial)
|
|
75
|
+
result = store.evaluate(estimate: 100.0, domain: :financial)
|
|
76
|
+
expect(result).to include(:pull_strength, :anchor_value, :correction)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
it 'correction is estimate minus anchored_estimate' do
|
|
80
|
+
store.add(value: 50.0, domain: :financial)
|
|
81
|
+
result = store.evaluate(estimate: 100.0, domain: :financial)
|
|
82
|
+
expect(result[:correction]).to be_within(0.001).of(100.0 - result[:anchored_estimate])
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
describe '#reference_frame' do
|
|
87
|
+
it 'returns neutral when no reference or anchor' do
|
|
88
|
+
result = store.reference_frame(value: 100.0, domain: :empty)
|
|
89
|
+
expect(result[:gain_or_loss]).to eq(:neutral)
|
|
90
|
+
expect(result[:magnitude]).to eq(0.0)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
it 'detects gain when value above reference' do
|
|
94
|
+
store.shift_reference(domain: :financial, new_reference: 50.0)
|
|
95
|
+
result = store.reference_frame(value: 100.0, domain: :financial)
|
|
96
|
+
expect(result[:gain_or_loss]).to eq(:gain)
|
|
97
|
+
expect(result[:magnitude]).to be > 0
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
it 'detects loss when value below reference' do
|
|
101
|
+
store.shift_reference(domain: :financial, new_reference: 100.0)
|
|
102
|
+
result = store.reference_frame(value: 50.0, domain: :financial)
|
|
103
|
+
expect(result[:gain_or_loss]).to eq(:loss)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
it 'applies loss aversion factor to losses' do
|
|
107
|
+
store.shift_reference(domain: :financial, new_reference: 100.0)
|
|
108
|
+
result = store.reference_frame(value: 50.0, domain: :financial)
|
|
109
|
+
raw_diff = 50.0
|
|
110
|
+
expected_magnitude = raw_diff * Legion::Extensions::Anchoring::Helpers::Constants::LOSS_AVERSION_FACTOR
|
|
111
|
+
expect(result[:magnitude]).to be_within(0.001).of(expected_magnitude)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
it 'does not apply loss aversion to gains' do
|
|
115
|
+
store.shift_reference(domain: :financial, new_reference: 50.0)
|
|
116
|
+
result = store.reference_frame(value: 100.0, domain: :financial)
|
|
117
|
+
expect(result[:magnitude]).to be_within(0.001).of(50.0)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
it 'uses anchor as implicit reference when no explicit reference set' do
|
|
121
|
+
store.add(value: 50.0, domain: :financial)
|
|
122
|
+
result = store.reference_frame(value: 100.0, domain: :financial)
|
|
123
|
+
expect(result[:gain_or_loss]).to eq(:gain)
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
describe '#decay_all' do
|
|
128
|
+
it 'decays all anchors and returns pruned count' do
|
|
129
|
+
store.add(value: 10.0, domain: :financial)
|
|
130
|
+
pruned = store.decay_all
|
|
131
|
+
expect(pruned).to be >= 0
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
it 'prunes anchors that fall below ANCHOR_FLOOR' do
|
|
135
|
+
anchor = store.add(value: 10.0, domain: :financial)
|
|
136
|
+
anchor.instance_variable_set(:@strength, Legion::Extensions::Anchoring::Helpers::Constants::ANCHOR_FLOOR - 0.001)
|
|
137
|
+
store.decay_all
|
|
138
|
+
expect(store.domains).not_to include(:financial)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
it 'retains anchors above floor' do
|
|
142
|
+
store.add(value: 10.0, domain: :financial)
|
|
143
|
+
store.decay_all
|
|
144
|
+
all = store.instance_variable_get(:@anchors)[:financial]
|
|
145
|
+
expect(all).not_to be_nil
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
describe '#shift_reference' do
|
|
150
|
+
it 'sets new reference point for domain' do
|
|
151
|
+
store.shift_reference(domain: :financial, new_reference: 100.0)
|
|
152
|
+
refs = store.instance_variable_get(:@references)
|
|
153
|
+
expect(refs[:financial]).to eq(100.0)
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
it 'returns old and new reference' do
|
|
157
|
+
store.shift_reference(domain: :financial, new_reference: 50.0)
|
|
158
|
+
result = store.shift_reference(domain: :financial, new_reference: 100.0)
|
|
159
|
+
expect(result[:old_reference]).to eq(50.0)
|
|
160
|
+
expect(result[:new_reference]).to eq(100.0)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
it 'marks shift as significant when diff >= REFERENCE_SHIFT_THRESHOLD' do
|
|
164
|
+
store.shift_reference(domain: :temporal, new_reference: 0.0)
|
|
165
|
+
result = store.shift_reference(domain: :temporal, new_reference: 1.0)
|
|
166
|
+
expect(result[:significant]).to be true
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
it 'marks shift as not significant when diff < REFERENCE_SHIFT_THRESHOLD' do
|
|
170
|
+
store.shift_reference(domain: :temporal, new_reference: 0.0)
|
|
171
|
+
result = store.shift_reference(domain: :temporal, new_reference: 0.1)
|
|
172
|
+
expect(result[:significant]).to be false
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
describe '#domains' do
|
|
177
|
+
it 'returns empty array when no anchors' do
|
|
178
|
+
expect(store.domains).to be_empty
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
it 'returns list of active domains' do
|
|
182
|
+
store.add(value: 1.0, domain: :financial)
|
|
183
|
+
store.add(value: 2.0, domain: :temporal)
|
|
184
|
+
expect(store.domains).to match_array(%i[financial temporal])
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
describe '#to_h' do
|
|
189
|
+
it 'returns summary hash' do
|
|
190
|
+
store.add(value: 10.0, domain: :financial)
|
|
191
|
+
h = store.to_h
|
|
192
|
+
expect(h).to include(:total_anchors, :domain_count, :domains, :references)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
it 'reflects correct total_anchors count' do
|
|
196
|
+
store.add(value: 10.0, domain: :financial)
|
|
197
|
+
store.add(value: 20.0, domain: :financial)
|
|
198
|
+
expect(store.to_h[:total_anchors]).to eq(2)
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe Legion::Extensions::Anchoring::Helpers::Constants do
|
|
4
|
+
it 'defines DEFAULT_ANCHOR_WEIGHT as 0.6' do
|
|
5
|
+
expect(described_module::DEFAULT_ANCHOR_WEIGHT).to eq(0.6)
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
it 'defines ADJUSTMENT_RATE as 0.1' do
|
|
9
|
+
expect(described_module::ADJUSTMENT_RATE).to eq(0.1)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
it 'defines ANCHOR_DECAY as 0.02' do
|
|
13
|
+
expect(described_module::ANCHOR_DECAY).to eq(0.02)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
it 'defines ANCHOR_FLOOR as 0.05' do
|
|
17
|
+
expect(described_module::ANCHOR_FLOOR).to eq(0.05)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
it 'defines MAX_ANCHORS_PER_DOMAIN as 20' do
|
|
21
|
+
expect(described_module::MAX_ANCHORS_PER_DOMAIN).to eq(20)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
it 'defines MAX_DOMAINS as 50' do
|
|
25
|
+
expect(described_module::MAX_DOMAINS).to eq(50)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
it 'defines REFERENCE_SHIFT_THRESHOLD as 0.3' do
|
|
29
|
+
expect(described_module::REFERENCE_SHIFT_THRESHOLD).to eq(0.3)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
it 'defines LOSS_AVERSION_FACTOR as 2.25' do
|
|
33
|
+
expect(described_module::LOSS_AVERSION_FACTOR).to eq(2.25)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
it 'defines ANCHOR_LABELS with 4 entries' do
|
|
37
|
+
expect(described_module::ANCHOR_LABELS.size).to eq(4)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
it 'ANCHOR_LABELS maps high strength to :strong' do
|
|
41
|
+
label = described_module::ANCHOR_LABELS.find { |range, _| range.cover?(0.9) }&.last
|
|
42
|
+
expect(label).to eq(:strong)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
it 'ANCHOR_LABELS maps mid strength to :moderate' do
|
|
46
|
+
label = described_module::ANCHOR_LABELS.find { |range, _| range.cover?(0.6) }&.last
|
|
47
|
+
expect(label).to eq(:moderate)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
it 'ANCHOR_LABELS maps low strength to :weak' do
|
|
51
|
+
label = described_module::ANCHOR_LABELS.find { |range, _| range.cover?(0.3) }&.last
|
|
52
|
+
expect(label).to eq(:weak)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
it 'ANCHOR_LABELS maps very low strength to :fading' do
|
|
56
|
+
label = described_module::ANCHOR_LABELS.find { |range, _| range.cover?(0.1) }&.last
|
|
57
|
+
expect(label).to eq(:fading)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def described_module
|
|
61
|
+
Legion::Extensions::Anchoring::Helpers::Constants
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/anchoring/client'
|
|
4
|
+
|
|
5
|
+
RSpec.describe Legion::Extensions::Anchoring::Runners::Anchoring do
|
|
6
|
+
let(:client) { Legion::Extensions::Anchoring::Client.new }
|
|
7
|
+
|
|
8
|
+
describe '#record_anchor' do
|
|
9
|
+
it 'returns success: true' do
|
|
10
|
+
result = client.record_anchor(value: 100.0, domain: :financial)
|
|
11
|
+
expect(result[:success]).to be true
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
it 'returns anchor hash' do
|
|
15
|
+
result = client.record_anchor(value: 100.0, domain: :financial)
|
|
16
|
+
expect(result[:anchor]).to include(:id, :value, :domain, :strength)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
it 'uses :general domain by default' do
|
|
20
|
+
result = client.record_anchor(value: 50.0)
|
|
21
|
+
expect(result[:anchor][:domain]).to eq(:general)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
it 'accepts keyword splat' do
|
|
25
|
+
expect { client.record_anchor(value: 10.0, extra: :ignored) }.not_to raise_error
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
describe '#evaluate_estimate' do
|
|
30
|
+
it 'returns success: true' do
|
|
31
|
+
result = client.evaluate_estimate(estimate: 100.0, domain: :financial)
|
|
32
|
+
expect(result[:success]).to be true
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
it 'returns no-pull when domain has no anchors' do
|
|
36
|
+
result = client.evaluate_estimate(estimate: 100.0, domain: :noanchor)
|
|
37
|
+
expect(result[:pull_strength]).to eq(0.0)
|
|
38
|
+
expect(result[:anchored_estimate]).to eq(100.0)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
it 'biases estimate toward anchor when anchor exists' do
|
|
42
|
+
client.record_anchor(value: 50.0, domain: :financial)
|
|
43
|
+
result = client.evaluate_estimate(estimate: 100.0, domain: :financial)
|
|
44
|
+
expect(result[:anchored_estimate]).to be < 100.0
|
|
45
|
+
expect(result[:anchored_estimate]).to be > 50.0
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
it 'includes pull_strength, anchor_value, correction' do
|
|
49
|
+
client.record_anchor(value: 50.0, domain: :financial)
|
|
50
|
+
result = client.evaluate_estimate(estimate: 100.0, domain: :financial)
|
|
51
|
+
expect(result).to include(:pull_strength, :anchor_value, :correction)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
describe '#reference_frame' do
|
|
56
|
+
it 'returns success: true' do
|
|
57
|
+
result = client.reference_frame(value: 100.0, domain: :financial)
|
|
58
|
+
expect(result[:success]).to be true
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
it 'returns neutral for domain without reference' do
|
|
62
|
+
result = client.reference_frame(value: 100.0, domain: :empty_domain)
|
|
63
|
+
expect(result[:gain_or_loss]).to eq(:neutral)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
it 'detects gain after setting reference' do
|
|
67
|
+
client.shift_reference(domain: :financial, new_reference: 50.0)
|
|
68
|
+
result = client.reference_frame(value: 100.0, domain: :financial)
|
|
69
|
+
expect(result[:gain_or_loss]).to eq(:gain)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
it 'detects loss after setting reference' do
|
|
73
|
+
client.shift_reference(domain: :financial, new_reference: 100.0)
|
|
74
|
+
result = client.reference_frame(value: 50.0, domain: :financial)
|
|
75
|
+
expect(result[:gain_or_loss]).to eq(:loss)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
it 'applies loss aversion factor to losses' do
|
|
79
|
+
client.shift_reference(domain: :financial, new_reference: 100.0)
|
|
80
|
+
result = client.reference_frame(value: 50.0, domain: :financial)
|
|
81
|
+
expect(result[:magnitude]).to be_within(0.001).of(50.0 * 2.25)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
describe '#de_anchor' do
|
|
86
|
+
it 'returns success: true' do
|
|
87
|
+
result = client.de_anchor(estimate: 100.0, domain: :empty_de)
|
|
88
|
+
expect(result[:success]).to be true
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
it 'returns original estimate with zero bias when no anchor' do
|
|
92
|
+
result = client.de_anchor(estimate: 100.0, domain: :no_anchor_domain)
|
|
93
|
+
expect(result[:corrected_estimate]).to eq(100.0)
|
|
94
|
+
expect(result[:anchor_bias]).to eq(0.0)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
it 'returns corrected estimate that removes anchor bias' do
|
|
98
|
+
client.record_anchor(value: 50.0, domain: :financial)
|
|
99
|
+
result = client.de_anchor(estimate: 100.0, domain: :financial)
|
|
100
|
+
expect(result[:corrected_estimate]).to be > 100.0
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
it 'returns anchor_value when anchor present' do
|
|
104
|
+
client.record_anchor(value: 50.0, domain: :financial)
|
|
105
|
+
result = client.de_anchor(estimate: 100.0, domain: :financial)
|
|
106
|
+
expect(result[:anchor_value]).to eq(50.0)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
it 'includes original_estimate' do
|
|
110
|
+
client.record_anchor(value: 50.0, domain: :financial)
|
|
111
|
+
result = client.de_anchor(estimate: 100.0, domain: :financial)
|
|
112
|
+
expect(result[:original_estimate]).to eq(100.0)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
describe '#shift_reference' do
|
|
117
|
+
it 'returns success: true' do
|
|
118
|
+
result = client.shift_reference(domain: :financial, new_reference: 100.0)
|
|
119
|
+
expect(result[:success]).to be true
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
it 'includes domain, old_reference, new_reference, significant' do
|
|
123
|
+
result = client.shift_reference(domain: :financial, new_reference: 100.0)
|
|
124
|
+
expect(result).to include(:domain, :old_reference, :new_reference, :significant)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
it 'marks large shift as significant' do
|
|
128
|
+
client.shift_reference(domain: :financial, new_reference: 0.0)
|
|
129
|
+
result = client.shift_reference(domain: :financial, new_reference: 1.0)
|
|
130
|
+
expect(result[:significant]).to be true
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
describe '#update_anchoring' do
|
|
135
|
+
it 'returns success: true' do
|
|
136
|
+
result = client.update_anchoring
|
|
137
|
+
expect(result[:success]).to be true
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
it 'returns pruned count' do
|
|
141
|
+
result = client.update_anchoring
|
|
142
|
+
expect(result).to have_key(:pruned)
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
it 'decays existing anchors' do
|
|
146
|
+
client.record_anchor(value: 100.0, domain: :financial)
|
|
147
|
+
anchor = client.anchor_store.strongest(domain: :financial)
|
|
148
|
+
before_strength = anchor.strength
|
|
149
|
+
client.update_anchoring
|
|
150
|
+
expect(anchor.strength).to be < before_strength
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
describe '#domain_anchors' do
|
|
155
|
+
it 'returns success: true' do
|
|
156
|
+
result = client.domain_anchors(domain: :financial)
|
|
157
|
+
expect(result[:success]).to be true
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
it 'returns empty anchors for unknown domain' do
|
|
161
|
+
result = client.domain_anchors(domain: :no_such_domain)
|
|
162
|
+
expect(result[:count]).to eq(0)
|
|
163
|
+
expect(result[:anchors]).to eq([])
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
it 'returns anchors for known domain' do
|
|
167
|
+
client.record_anchor(value: 100.0, domain: :financial)
|
|
168
|
+
result = client.domain_anchors(domain: :financial)
|
|
169
|
+
expect(result[:count]).to eq(1)
|
|
170
|
+
expect(result[:anchors].first[:value]).to eq(100.0)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
it 'includes strongest anchor' do
|
|
174
|
+
client.record_anchor(value: 100.0, domain: :financial)
|
|
175
|
+
result = client.domain_anchors(domain: :financial)
|
|
176
|
+
expect(result[:strongest]).not_to be_nil
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
describe '#anchoring_stats' do
|
|
181
|
+
it 'returns success: true' do
|
|
182
|
+
result = client.anchoring_stats
|
|
183
|
+
expect(result[:success]).to be true
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
it 'includes total_anchors, domain_count, domains' do
|
|
187
|
+
result = client.anchoring_stats
|
|
188
|
+
expect(result).to include(:total_anchors, :domain_count, :domains)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
it 'reflects anchor counts' do
|
|
192
|
+
client.record_anchor(value: 10.0, domain: :financial)
|
|
193
|
+
client.record_anchor(value: 20.0, domain: :temporal)
|
|
194
|
+
result = client.anchoring_stats
|
|
195
|
+
expect(result[:total_anchors]).to eq(2)
|
|
196
|
+
expect(result[:domain_count]).to eq(2)
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
data/spec/spec_helper.rb
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
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
|
+
|
|
13
|
+
module Extensions
|
|
14
|
+
module Helpers
|
|
15
|
+
module Lex; end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
require 'legion/extensions/anchoring'
|
|
21
|
+
require 'legion/extensions/anchoring/client'
|
|
22
|
+
|
|
23
|
+
RSpec.configure do |config|
|
|
24
|
+
config.example_status_persistence_file_path = '.rspec_status'
|
|
25
|
+
config.disable_monkey_patching!
|
|
26
|
+
config.expect_with(:rspec) { |c| c.syntax = :expect }
|
|
27
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: lex-anchoring
|
|
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: Decision anchoring and reference point effects for brain-modeled agentic
|
|
27
|
+
AI
|
|
28
|
+
email:
|
|
29
|
+
- matthewdiverson@gmail.com
|
|
30
|
+
executables: []
|
|
31
|
+
extensions: []
|
|
32
|
+
extra_rdoc_files: []
|
|
33
|
+
files:
|
|
34
|
+
- Gemfile
|
|
35
|
+
- LICENSE
|
|
36
|
+
- README.md
|
|
37
|
+
- lex-anchoring.gemspec
|
|
38
|
+
- lib/legion/extensions/anchoring.rb
|
|
39
|
+
- lib/legion/extensions/anchoring/client.rb
|
|
40
|
+
- lib/legion/extensions/anchoring/helpers/anchor.rb
|
|
41
|
+
- lib/legion/extensions/anchoring/helpers/anchor_store.rb
|
|
42
|
+
- lib/legion/extensions/anchoring/helpers/constants.rb
|
|
43
|
+
- lib/legion/extensions/anchoring/runners/anchoring.rb
|
|
44
|
+
- lib/legion/extensions/anchoring/version.rb
|
|
45
|
+
- spec/legion/extensions/anchoring/client_spec.rb
|
|
46
|
+
- spec/legion/extensions/anchoring/helpers/anchor_spec.rb
|
|
47
|
+
- spec/legion/extensions/anchoring/helpers/anchor_store_spec.rb
|
|
48
|
+
- spec/legion/extensions/anchoring/helpers/constants_spec.rb
|
|
49
|
+
- spec/legion/extensions/anchoring/runners/anchoring_spec.rb
|
|
50
|
+
- spec/spec_helper.rb
|
|
51
|
+
homepage: https://github.com/LegionIO/lex-anchoring
|
|
52
|
+
licenses:
|
|
53
|
+
- MIT
|
|
54
|
+
metadata:
|
|
55
|
+
homepage_uri: https://github.com/LegionIO/lex-anchoring
|
|
56
|
+
source_code_uri: https://github.com/LegionIO/lex-anchoring
|
|
57
|
+
documentation_uri: https://github.com/LegionIO/lex-anchoring
|
|
58
|
+
changelog_uri: https://github.com/LegionIO/lex-anchoring
|
|
59
|
+
bug_tracker_uri: https://github.com/LegionIO/lex-anchoring/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 Anchoring
|
|
78
|
+
test_files: []
|