lex-temporal-discounting 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/lex-temporal-discounting.gemspec +29 -0
- data/lib/legion/extensions/temporal_discounting/client.rb +20 -0
- data/lib/legion/extensions/temporal_discounting/helpers/constants.rb +31 -0
- data/lib/legion/extensions/temporal_discounting/helpers/discounting_engine.rb +162 -0
- data/lib/legion/extensions/temporal_discounting/helpers/reward.rb +80 -0
- data/lib/legion/extensions/temporal_discounting/runners/temporal_discounting.rb +119 -0
- data/lib/legion/extensions/temporal_discounting/version.rb +9 -0
- data/lib/legion/extensions/temporal_discounting.rb +15 -0
- data/spec/legion/extensions/temporal_discounting/client_spec.rb +26 -0
- data/spec/legion/extensions/temporal_discounting/helpers/discounting_engine_spec.rb +192 -0
- data/spec/legion/extensions/temporal_discounting/helpers/reward_spec.rb +189 -0
- data/spec/legion/extensions/temporal_discounting/runners/temporal_discounting_spec.rb +143 -0
- data/spec/spec_helper.rb +23 -0
- metadata +75 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 21a237076ed20a409650645d82f97cc71d6ebb19887c1086292784145ea76d70
|
|
4
|
+
data.tar.gz: f5d63d9514bca73170da0ca88625129e6d5f1d3cb7f40f6bee93b29de14d49d1
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: a583760854c89b814f44f51e70f63fa1300e36a7ce5c3604865b78fe4721f8209f9a1aded7eeabaaa2606e9262b8b8ad19387daa5a6130d50c61f16be48c9051
|
|
7
|
+
data.tar.gz: b0e1c81d05ce3ad7a101a7be8a2d04ac5dd206927eb77265fed9a573d6a71b220b2c17b443f085d13d112e53fd70ef0b11319406a2561876c16c757f09c514dc
|
data/Gemfile
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'lib/legion/extensions/temporal_discounting/version'
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = 'lex-temporal-discounting'
|
|
7
|
+
spec.version = Legion::Extensions::TemporalDiscounting::VERSION
|
|
8
|
+
spec.authors = ['Esity']
|
|
9
|
+
spec.email = ['matthewdiverson@gmail.com']
|
|
10
|
+
|
|
11
|
+
spec.summary = 'LEX Temporal Discounting'
|
|
12
|
+
spec.description = 'Hyperbolic temporal discounting model for brain-modeled agentic AI planning and impulse control'
|
|
13
|
+
spec.homepage = 'https://github.com/LegionIO/lex-temporal-discounting'
|
|
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-temporal-discounting'
|
|
19
|
+
spec.metadata['documentation_uri'] = 'https://github.com/LegionIO/lex-temporal-discounting'
|
|
20
|
+
spec.metadata['changelog_uri'] = 'https://github.com/LegionIO/lex-temporal-discounting'
|
|
21
|
+
spec.metadata['bug_tracker_uri'] = 'https://github.com/LegionIO/lex-temporal-discounting/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-temporal-discounting.gemspec Gemfile]
|
|
26
|
+
end
|
|
27
|
+
spec.require_paths = ['lib']
|
|
28
|
+
spec.add_development_dependency 'legion-gaia'
|
|
29
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/temporal_discounting/helpers/constants'
|
|
4
|
+
require 'legion/extensions/temporal_discounting/helpers/reward'
|
|
5
|
+
require 'legion/extensions/temporal_discounting/helpers/discounting_engine'
|
|
6
|
+
require 'legion/extensions/temporal_discounting/runners/temporal_discounting'
|
|
7
|
+
|
|
8
|
+
module Legion
|
|
9
|
+
module Extensions
|
|
10
|
+
module TemporalDiscounting
|
|
11
|
+
class Client
|
|
12
|
+
include Runners::TemporalDiscounting
|
|
13
|
+
|
|
14
|
+
def engine
|
|
15
|
+
@engine ||= Helpers::DiscountingEngine.new
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module TemporalDiscounting
|
|
6
|
+
module Helpers
|
|
7
|
+
DEFAULT_DISCOUNT_RATE = 0.1
|
|
8
|
+
MIN_DISCOUNT_RATE = 0.01
|
|
9
|
+
MAX_DISCOUNT_RATE = 1.0
|
|
10
|
+
MAX_REWARDS = 500
|
|
11
|
+
DEFAULT_DELAY = 1.0
|
|
12
|
+
|
|
13
|
+
IMPULSIVITY_LABELS = {
|
|
14
|
+
(0.0...0.05) => :patient,
|
|
15
|
+
(0.05...0.15) => :moderate,
|
|
16
|
+
(0.15...0.3) => :impulsive,
|
|
17
|
+
(0.3...0.6) => :very_impulsive,
|
|
18
|
+
(0.6..1.0) => :extreme
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
VALUE_LABELS = {
|
|
22
|
+
(0.8..) => :full_value,
|
|
23
|
+
(0.6...0.8) => :high_value,
|
|
24
|
+
(0.4...0.6) => :moderate_value,
|
|
25
|
+
(0.2...0.4) => :low_value,
|
|
26
|
+
(..0.2) => :negligible
|
|
27
|
+
}.freeze
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module TemporalDiscounting
|
|
6
|
+
module Helpers
|
|
7
|
+
class DiscountingEngine
|
|
8
|
+
attr_reader :rewards, :domain_rates
|
|
9
|
+
|
|
10
|
+
def initialize
|
|
11
|
+
@rewards = {}
|
|
12
|
+
@domain_rates = {}
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def create_reward(label:, amount:, delay:, domain: :general, discount_rate: nil)
|
|
16
|
+
rate = discount_rate || get_domain_rate(domain)
|
|
17
|
+
reward = Reward.new(label: label, amount: amount, delay: delay, domain: domain, discount_rate: rate)
|
|
18
|
+
@rewards[reward.id] = reward
|
|
19
|
+
@rewards.shift while @rewards.size > MAX_REWARDS
|
|
20
|
+
reward
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def compare_rewards(reward_a_id:, reward_b_id:)
|
|
24
|
+
a = @rewards.fetch(reward_a_id, nil)
|
|
25
|
+
b = @rewards.fetch(reward_b_id, nil)
|
|
26
|
+
return { error: :not_found, missing: missing_ids(reward_a_id, reward_b_id) } if a.nil? || b.nil?
|
|
27
|
+
|
|
28
|
+
delta = (a.subjective_value - b.subjective_value).round(10)
|
|
29
|
+
preferred = if delta.positive?
|
|
30
|
+
reward_a_id
|
|
31
|
+
elsif delta.negative?
|
|
32
|
+
reward_b_id
|
|
33
|
+
else
|
|
34
|
+
:tied
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
{
|
|
38
|
+
preferred: preferred,
|
|
39
|
+
delta: delta.abs,
|
|
40
|
+
reward_a_value: a.subjective_value,
|
|
41
|
+
reward_b_value: b.subjective_value
|
|
42
|
+
}
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def worth_waiting_for?(reward_id:, threshold: 0.3)
|
|
46
|
+
reward = @rewards.fetch(reward_id, nil)
|
|
47
|
+
return { error: :not_found } if reward.nil?
|
|
48
|
+
|
|
49
|
+
{ reward_id: reward_id, worth_waiting: reward.worth_waiting?(threshold: threshold),
|
|
50
|
+
subjective_value: reward.subjective_value, threshold: threshold }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def set_domain_rate(domain:, rate:)
|
|
54
|
+
@domain_rates[domain] = rate.clamp(MIN_DISCOUNT_RATE, MAX_DISCOUNT_RATE)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def get_domain_rate(domain)
|
|
58
|
+
@domain_rates.fetch(domain, DEFAULT_DISCOUNT_RATE)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def immediate_vs_delayed(immediate_amount:, delayed_amount:, delay:, domain: :general)
|
|
62
|
+
k = get_domain_rate(domain)
|
|
63
|
+
delayed_sv = (delayed_amount / (1.0 + (k * delay.to_f))).round(10)
|
|
64
|
+
immediate_sv = immediate_amount.to_f.clamp(0.0, 1.0).round(10)
|
|
65
|
+
|
|
66
|
+
preferred = immediate_sv >= delayed_sv ? :immediate : :delayed
|
|
67
|
+
|
|
68
|
+
{
|
|
69
|
+
preferred: preferred,
|
|
70
|
+
immediate_value: immediate_sv,
|
|
71
|
+
delayed_value: delayed_sv,
|
|
72
|
+
delta: (immediate_sv - delayed_sv).abs.round(10),
|
|
73
|
+
discount_rate: k
|
|
74
|
+
}
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def optimal_delay(reward_id:, min_value: 0.5)
|
|
78
|
+
reward = @rewards.fetch(reward_id, nil)
|
|
79
|
+
return { error: :not_found } if reward.nil?
|
|
80
|
+
|
|
81
|
+
k = reward.discount_rate
|
|
82
|
+
a = reward.amount
|
|
83
|
+
return { error: :threshold_too_high } if min_value > a
|
|
84
|
+
|
|
85
|
+
max_delay = ((a / min_value) - 1.0) / k
|
|
86
|
+
{
|
|
87
|
+
reward_id: reward_id,
|
|
88
|
+
max_delay: max_delay.round(10),
|
|
89
|
+
min_value: min_value,
|
|
90
|
+
amount: a,
|
|
91
|
+
discount_rate: k
|
|
92
|
+
}
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def patience_report
|
|
96
|
+
return empty_patience_report if @rewards.empty?
|
|
97
|
+
|
|
98
|
+
values = @rewards.values
|
|
99
|
+
avg_rate = (values.sum(&:discount_rate) / values.size).round(10)
|
|
100
|
+
avg_sv = (values.sum(&:subjective_value) / values.size).round(10)
|
|
101
|
+
distribution = build_impulsivity_distribution(values)
|
|
102
|
+
|
|
103
|
+
{
|
|
104
|
+
total_rewards: @rewards.size,
|
|
105
|
+
avg_discount_rate: avg_rate,
|
|
106
|
+
avg_subjective_value: avg_sv,
|
|
107
|
+
impulsivity_distribution: distribution
|
|
108
|
+
}
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def rewards_by_domain(domain:)
|
|
112
|
+
@rewards.values.select { |r| r.domain == domain }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def most_valuable(limit: 5)
|
|
116
|
+
@rewards.values
|
|
117
|
+
.sort_by { |r| -r.subjective_value }
|
|
118
|
+
.first(limit)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def prune_expired(min_value: 0.05)
|
|
122
|
+
before = @rewards.size
|
|
123
|
+
@rewards.reject! { |_, r| r.subjective_value < min_value }
|
|
124
|
+
{ pruned: before - @rewards.size, remaining: @rewards.size }
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def to_h
|
|
128
|
+
{
|
|
129
|
+
total_rewards: @rewards.size,
|
|
130
|
+
domain_rates: @domain_rates,
|
|
131
|
+
patience: patience_report
|
|
132
|
+
}
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
private
|
|
136
|
+
|
|
137
|
+
def missing_ids(id_a, id_b)
|
|
138
|
+
missing = []
|
|
139
|
+
missing << id_a unless @rewards.key?(id_a)
|
|
140
|
+
missing << id_b unless @rewards.key?(id_b)
|
|
141
|
+
missing
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def empty_patience_report
|
|
145
|
+
{
|
|
146
|
+
total_rewards: 0,
|
|
147
|
+
avg_discount_rate: 0.0,
|
|
148
|
+
avg_subjective_value: 0.0,
|
|
149
|
+
impulsivity_distribution: {}
|
|
150
|
+
}
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def build_impulsivity_distribution(values)
|
|
154
|
+
distribution = Hash.new(0)
|
|
155
|
+
values.each { |r| distribution[r.impulsivity_label] += 1 }
|
|
156
|
+
distribution
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module Extensions
|
|
7
|
+
module TemporalDiscounting
|
|
8
|
+
module Helpers
|
|
9
|
+
class Reward
|
|
10
|
+
attr_reader :id, :label, :amount, :delay, :domain, :discount_rate, :created_at
|
|
11
|
+
|
|
12
|
+
def initialize(label:, amount:, delay:, domain: :general, discount_rate: DEFAULT_DISCOUNT_RATE)
|
|
13
|
+
@id = SecureRandom.uuid
|
|
14
|
+
@label = label
|
|
15
|
+
@amount = amount.clamp(0.0, 1.0)
|
|
16
|
+
@delay = delay.to_f
|
|
17
|
+
@domain = domain
|
|
18
|
+
@discount_rate = discount_rate.clamp(MIN_DISCOUNT_RATE, MAX_DISCOUNT_RATE)
|
|
19
|
+
@created_at = Time.now.utc
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def subjective_value
|
|
23
|
+
(@amount / (1.0 + (@discount_rate * @delay))).round(10)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def value_ratio
|
|
27
|
+
return 0.0 if @amount.zero?
|
|
28
|
+
|
|
29
|
+
(subjective_value / @amount).round(10)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def value_label
|
|
33
|
+
ratio = value_ratio
|
|
34
|
+
VALUE_LABELS.each do |range, label|
|
|
35
|
+
return label if range.cover?(ratio)
|
|
36
|
+
end
|
|
37
|
+
:negligible
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def impulsivity_label
|
|
41
|
+
k = @discount_rate
|
|
42
|
+
IMPULSIVITY_LABELS.each do |range, label|
|
|
43
|
+
return label if range.cover?(k)
|
|
44
|
+
end
|
|
45
|
+
:extreme
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def worth_waiting?(threshold: 0.3)
|
|
49
|
+
subjective_value >= threshold
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def adjust_delay!(new_delay:)
|
|
53
|
+
@delay = new_delay.to_f
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def adjust_discount_rate!(new_rate:)
|
|
57
|
+
@discount_rate = new_rate.clamp(MIN_DISCOUNT_RATE, MAX_DISCOUNT_RATE)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def to_h
|
|
61
|
+
{
|
|
62
|
+
id: @id,
|
|
63
|
+
label: @label,
|
|
64
|
+
amount: @amount,
|
|
65
|
+
delay: @delay,
|
|
66
|
+
domain: @domain,
|
|
67
|
+
discount_rate: @discount_rate,
|
|
68
|
+
subjective_value: subjective_value,
|
|
69
|
+
value_ratio: value_ratio,
|
|
70
|
+
value_label: value_label,
|
|
71
|
+
impulsivity_label: impulsivity_label,
|
|
72
|
+
worth_waiting: worth_waiting?,
|
|
73
|
+
created_at: @created_at
|
|
74
|
+
}
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module TemporalDiscounting
|
|
6
|
+
module Runners
|
|
7
|
+
module TemporalDiscounting
|
|
8
|
+
include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers) &&
|
|
9
|
+
Legion::Extensions::Helpers.const_defined?(:Lex)
|
|
10
|
+
|
|
11
|
+
def create_temporal_reward(label:, amount:, delay:, domain: :general, discount_rate: nil, **)
|
|
12
|
+
reward = engine.create_reward(label: label, amount: amount, delay: delay,
|
|
13
|
+
domain: domain, discount_rate: discount_rate)
|
|
14
|
+
Legion::Logging.debug "[temporal_discounting] created reward id=#{reward.id[0..7]} " \
|
|
15
|
+
"sv=#{reward.subjective_value.round(4)} label=#{label}"
|
|
16
|
+
{ success: true, reward: reward.to_h }
|
|
17
|
+
rescue StandardError => e
|
|
18
|
+
Legion::Logging.warn "[temporal_discounting] create_temporal_reward failed: #{e.message}"
|
|
19
|
+
{ success: false, error: e.message }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def compare_temporal_rewards(reward_a_id:, reward_b_id:, **)
|
|
23
|
+
result = engine.compare_rewards(reward_a_id: reward_a_id, reward_b_id: reward_b_id)
|
|
24
|
+
Legion::Logging.debug "[temporal_discounting] compare preferred=#{result[:preferred]}"
|
|
25
|
+
{ success: !result.key?(:error), **result }
|
|
26
|
+
rescue StandardError => e
|
|
27
|
+
Legion::Logging.warn "[temporal_discounting] compare_temporal_rewards failed: #{e.message}"
|
|
28
|
+
{ success: false, error: e.message }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def check_worth_waiting(reward_id:, threshold: 0.3, **)
|
|
32
|
+
result = engine.worth_waiting_for?(reward_id: reward_id, threshold: threshold)
|
|
33
|
+
Legion::Logging.debug "[temporal_discounting] worth_waiting=#{result[:worth_waiting]} id=#{reward_id[0..7]}"
|
|
34
|
+
{ success: !result.key?(:error), **result }
|
|
35
|
+
rescue StandardError => e
|
|
36
|
+
Legion::Logging.warn "[temporal_discounting] check_worth_waiting failed: #{e.message}"
|
|
37
|
+
{ success: false, error: e.message }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def immediate_vs_delayed_comparison(immediate_amount:, delayed_amount:, delay:, domain: :general, **)
|
|
41
|
+
result = engine.immediate_vs_delayed(
|
|
42
|
+
immediate_amount: immediate_amount,
|
|
43
|
+
delayed_amount: delayed_amount,
|
|
44
|
+
delay: delay,
|
|
45
|
+
domain: domain
|
|
46
|
+
)
|
|
47
|
+
Legion::Logging.debug "[temporal_discounting] imm_vs_delayed preferred=#{result[:preferred]}"
|
|
48
|
+
{ success: true, **result }
|
|
49
|
+
rescue StandardError => e
|
|
50
|
+
Legion::Logging.warn "[temporal_discounting] immediate_vs_delayed_comparison failed: #{e.message}"
|
|
51
|
+
{ success: false, error: e.message }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def compute_optimal_delay(reward_id:, min_value: 0.5, **)
|
|
55
|
+
result = engine.optimal_delay(reward_id: reward_id, min_value: min_value)
|
|
56
|
+
Legion::Logging.debug "[temporal_discounting] optimal_delay=#{result[:max_delay]} id=#{reward_id[0..7]}"
|
|
57
|
+
{ success: !result.key?(:error), **result }
|
|
58
|
+
rescue StandardError => e
|
|
59
|
+
Legion::Logging.warn "[temporal_discounting] compute_optimal_delay failed: #{e.message}"
|
|
60
|
+
{ success: false, error: e.message }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def temporal_patience_report(**)
|
|
64
|
+
report = engine.patience_report
|
|
65
|
+
Legion::Logging.debug "[temporal_discounting] patience_report total=#{report[:total_rewards]}"
|
|
66
|
+
{ success: true, **report }
|
|
67
|
+
rescue StandardError => e
|
|
68
|
+
Legion::Logging.warn "[temporal_discounting] temporal_patience_report failed: #{e.message}"
|
|
69
|
+
{ success: false, error: e.message }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def set_domain_discount_rate(domain:, rate:, **)
|
|
73
|
+
engine.set_domain_rate(domain: domain, rate: rate)
|
|
74
|
+
Legion::Logging.debug "[temporal_discounting] set domain=#{domain} rate=#{rate}"
|
|
75
|
+
{ success: true, domain: domain, rate: engine.get_domain_rate(domain) }
|
|
76
|
+
rescue StandardError => e
|
|
77
|
+
Legion::Logging.warn "[temporal_discounting] set_domain_discount_rate failed: #{e.message}"
|
|
78
|
+
{ success: false, error: e.message }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def most_valuable_rewards(limit: 5, **)
|
|
82
|
+
rewards = engine.most_valuable(limit: limit)
|
|
83
|
+
Legion::Logging.debug "[temporal_discounting] most_valuable count=#{rewards.size}"
|
|
84
|
+
{ success: true, rewards: rewards.map(&:to_h), count: rewards.size }
|
|
85
|
+
rescue StandardError => e
|
|
86
|
+
Legion::Logging.warn "[temporal_discounting] most_valuable_rewards failed: #{e.message}"
|
|
87
|
+
{ success: false, error: e.message }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def update_temporal_discounting(min_value: 0.05, **)
|
|
91
|
+
prune_result = engine.prune_expired(min_value: min_value)
|
|
92
|
+
stats = engine.to_h
|
|
93
|
+
Legion::Logging.debug "[temporal_discounting] update pruned=#{prune_result[:pruned]} " \
|
|
94
|
+
"remaining=#{prune_result[:remaining]}"
|
|
95
|
+
{ success: true, pruned: prune_result[:pruned], remaining: prune_result[:remaining], stats: stats }
|
|
96
|
+
rescue StandardError => e
|
|
97
|
+
Legion::Logging.warn "[temporal_discounting] update_temporal_discounting failed: #{e.message}"
|
|
98
|
+
{ success: false, error: e.message }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def temporal_discounting_stats(**)
|
|
102
|
+
stats = engine.to_h
|
|
103
|
+
Legion::Logging.debug "[temporal_discounting] stats total=#{stats[:total_rewards]}"
|
|
104
|
+
{ success: true, **stats }
|
|
105
|
+
rescue StandardError => e
|
|
106
|
+
Legion::Logging.warn "[temporal_discounting] temporal_discounting_stats failed: #{e.message}"
|
|
107
|
+
{ success: false, error: e.message }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
private
|
|
111
|
+
|
|
112
|
+
def engine
|
|
113
|
+
@engine ||= Helpers::DiscountingEngine.new
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/temporal_discounting/version'
|
|
4
|
+
require 'legion/extensions/temporal_discounting/helpers/constants'
|
|
5
|
+
require 'legion/extensions/temporal_discounting/helpers/reward'
|
|
6
|
+
require 'legion/extensions/temporal_discounting/helpers/discounting_engine'
|
|
7
|
+
require 'legion/extensions/temporal_discounting/runners/temporal_discounting'
|
|
8
|
+
|
|
9
|
+
module Legion
|
|
10
|
+
module Extensions
|
|
11
|
+
module TemporalDiscounting
|
|
12
|
+
extend Legion::Extensions::Core if Legion::Extensions.const_defined? :Core
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/temporal_discounting/client'
|
|
4
|
+
|
|
5
|
+
RSpec.describe Legion::Extensions::TemporalDiscounting::Client do
|
|
6
|
+
subject(:client) { described_class.new }
|
|
7
|
+
|
|
8
|
+
it 'includes the TemporalDiscounting runner' do
|
|
9
|
+
expect(client).to respond_to(:create_temporal_reward)
|
|
10
|
+
expect(client).to respond_to(:compare_temporal_rewards)
|
|
11
|
+
expect(client).to respond_to(:temporal_patience_report)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
it 'exposes the discounting engine' do
|
|
15
|
+
expect(client.engine).to be_a(Legion::Extensions::TemporalDiscounting::Helpers::DiscountingEngine)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
it 'shares the same engine instance across calls' do
|
|
19
|
+
expect(client.engine).to equal(client.engine)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
it 'creates different engine instances per client' do
|
|
23
|
+
c2 = described_class.new
|
|
24
|
+
expect(client.engine).not_to equal(c2.engine)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/temporal_discounting/client'
|
|
4
|
+
|
|
5
|
+
RSpec.describe Legion::Extensions::TemporalDiscounting::Helpers::DiscountingEngine do
|
|
6
|
+
subject(:engine) { described_class.new }
|
|
7
|
+
|
|
8
|
+
describe '#create_reward' do
|
|
9
|
+
it 'creates and stores a reward' do
|
|
10
|
+
reward = engine.create_reward(label: 'bonus', amount: 0.7, delay: 3.0)
|
|
11
|
+
expect(reward).to be_a(Legion::Extensions::TemporalDiscounting::Helpers::Reward)
|
|
12
|
+
expect(engine.rewards).to have_key(reward.id)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
it 'uses domain rate when no discount_rate given' do
|
|
16
|
+
engine.set_domain_rate(domain: :financial, rate: 0.25)
|
|
17
|
+
reward = engine.create_reward(label: 'fin', amount: 0.5, delay: 2.0, domain: :financial)
|
|
18
|
+
expect(reward.discount_rate).to eq(0.25)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
it 'uses explicit discount_rate over domain rate' do
|
|
22
|
+
engine.set_domain_rate(domain: :financial, rate: 0.25)
|
|
23
|
+
reward = engine.create_reward(label: 'fin', amount: 0.5, delay: 2.0, domain: :financial, discount_rate: 0.05)
|
|
24
|
+
expect(reward.discount_rate).to eq(0.05)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
describe '#compare_rewards' do
|
|
29
|
+
it 'returns preferred reward and delta' do
|
|
30
|
+
a = engine.create_reward(label: 'a', amount: 0.9, delay: 1.0, discount_rate: 0.1)
|
|
31
|
+
b = engine.create_reward(label: 'b', amount: 0.3, delay: 1.0, discount_rate: 0.1)
|
|
32
|
+
result = engine.compare_rewards(reward_a_id: a.id, reward_b_id: b.id)
|
|
33
|
+
expect(result[:preferred]).to eq(a.id)
|
|
34
|
+
expect(result[:delta]).to be > 0
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
it 'returns tied when values are equal' do
|
|
38
|
+
a = engine.create_reward(label: 'a', amount: 0.5, delay: 0.0, discount_rate: 0.1)
|
|
39
|
+
b = engine.create_reward(label: 'b', amount: 0.5, delay: 0.0, discount_rate: 0.1)
|
|
40
|
+
result = engine.compare_rewards(reward_a_id: a.id, reward_b_id: b.id)
|
|
41
|
+
expect(result[:preferred]).to eq(:tied)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
it 'returns error for missing reward' do
|
|
45
|
+
a = engine.create_reward(label: 'a', amount: 0.5, delay: 1.0)
|
|
46
|
+
result = engine.compare_rewards(reward_a_id: a.id, reward_b_id: 'missing')
|
|
47
|
+
expect(result[:error]).to eq(:not_found)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
describe '#worth_waiting_for?' do
|
|
52
|
+
it 'returns true when subjective_value >= threshold' do
|
|
53
|
+
r = engine.create_reward(label: 'x', amount: 0.9, delay: 0.5, discount_rate: 0.1)
|
|
54
|
+
result = engine.worth_waiting_for?(reward_id: r.id, threshold: 0.3)
|
|
55
|
+
expect(result[:worth_waiting]).to be true
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
it 'returns error for unknown reward' do
|
|
59
|
+
result = engine.worth_waiting_for?(reward_id: 'nope')
|
|
60
|
+
expect(result[:error]).to eq(:not_found)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
describe '#set_domain_rate / #get_domain_rate' do
|
|
65
|
+
it 'stores and retrieves domain rate' do
|
|
66
|
+
engine.set_domain_rate(domain: :health, rate: 0.3)
|
|
67
|
+
expect(engine.get_domain_rate(:health)).to eq(0.3)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
it 'returns DEFAULT_DISCOUNT_RATE for unknown domain' do
|
|
71
|
+
expect(engine.get_domain_rate(:unknown)).to eq(Legion::Extensions::TemporalDiscounting::Helpers::DEFAULT_DISCOUNT_RATE)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
it 'clamps domain rate to MAX' do
|
|
75
|
+
engine.set_domain_rate(domain: :test, rate: 5.0)
|
|
76
|
+
expect(engine.get_domain_rate(:test)).to eq(Legion::Extensions::TemporalDiscounting::Helpers::MAX_DISCOUNT_RATE)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
describe '#immediate_vs_delayed' do
|
|
81
|
+
it 'prefers immediate when immediate_amount > delayed discounted value' do
|
|
82
|
+
result = engine.immediate_vs_delayed(immediate_amount: 0.8, delayed_amount: 0.9, delay: 100.0)
|
|
83
|
+
expect(result[:preferred]).to eq(:immediate)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
it 'prefers delayed when delayed discounted value > immediate' do
|
|
87
|
+
result = engine.immediate_vs_delayed(immediate_amount: 0.1, delayed_amount: 0.9, delay: 0.1)
|
|
88
|
+
expect(result[:preferred]).to eq(:delayed)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
it 'uses domain rate in computation' do
|
|
92
|
+
engine.set_domain_rate(domain: :social, rate: 0.5)
|
|
93
|
+
result = engine.immediate_vs_delayed(immediate_amount: 0.4, delayed_amount: 0.9, delay: 2.0, domain: :social)
|
|
94
|
+
expect(result[:discount_rate]).to eq(0.5)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
it 'returns delta between values' do
|
|
98
|
+
result = engine.immediate_vs_delayed(immediate_amount: 0.5, delayed_amount: 0.8, delay: 5.0)
|
|
99
|
+
expect(result[:delta]).to be >= 0
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
describe '#optimal_delay' do
|
|
104
|
+
it 'computes maximum delay before value drops below threshold' do
|
|
105
|
+
r = engine.create_reward(label: 'x', amount: 0.8, delay: 1.0, discount_rate: 0.1)
|
|
106
|
+
result = engine.optimal_delay(reward_id: r.id, min_value: 0.4)
|
|
107
|
+
# D = (A/V - 1) / k = (0.8/0.4 - 1) / 0.1 = 1/0.1 = 10
|
|
108
|
+
expect(result[:max_delay]).to be_within(1e-9).of(10.0)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
it 'returns error when reward not found' do
|
|
112
|
+
result = engine.optimal_delay(reward_id: 'none')
|
|
113
|
+
expect(result[:error]).to eq(:not_found)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
it 'returns error when min_value > amount' do
|
|
117
|
+
r = engine.create_reward(label: 'x', amount: 0.3, delay: 1.0)
|
|
118
|
+
result = engine.optimal_delay(reward_id: r.id, min_value: 0.5)
|
|
119
|
+
expect(result[:error]).to eq(:threshold_too_high)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
describe '#patience_report' do
|
|
124
|
+
it 'returns empty report when no rewards' do
|
|
125
|
+
report = engine.patience_report
|
|
126
|
+
expect(report[:total_rewards]).to eq(0)
|
|
127
|
+
expect(report[:avg_discount_rate]).to eq(0.0)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
it 'computes avg discount rate and subjective value' do
|
|
131
|
+
engine.create_reward(label: 'a', amount: 0.8, delay: 2.0, discount_rate: 0.1)
|
|
132
|
+
engine.create_reward(label: 'b', amount: 0.6, delay: 2.0, discount_rate: 0.3)
|
|
133
|
+
report = engine.patience_report
|
|
134
|
+
expect(report[:total_rewards]).to eq(2)
|
|
135
|
+
expect(report[:avg_discount_rate]).to be_within(1e-9).of(0.2)
|
|
136
|
+
expect(report[:avg_subjective_value]).to be > 0
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
it 'includes impulsivity distribution' do
|
|
140
|
+
engine.create_reward(label: 'a', amount: 0.8, delay: 1.0, discount_rate: 0.02)
|
|
141
|
+
engine.create_reward(label: 'b', amount: 0.8, delay: 1.0, discount_rate: 0.5)
|
|
142
|
+
report = engine.patience_report
|
|
143
|
+
expect(report[:impulsivity_distribution]).to have_key(:patient)
|
|
144
|
+
expect(report[:impulsivity_distribution]).to have_key(:very_impulsive)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
describe '#rewards_by_domain' do
|
|
149
|
+
it 'filters rewards by domain' do
|
|
150
|
+
engine.create_reward(label: 'fin', amount: 0.5, delay: 1.0, domain: :financial)
|
|
151
|
+
engine.create_reward(label: 'hth', amount: 0.5, delay: 1.0, domain: :health)
|
|
152
|
+
engine.create_reward(label: 'fin2', amount: 0.7, delay: 1.0, domain: :financial)
|
|
153
|
+
result = engine.rewards_by_domain(domain: :financial)
|
|
154
|
+
expect(result.size).to eq(2)
|
|
155
|
+
expect(result.all? { |r| r.domain == :financial }).to be true
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
describe '#most_valuable' do
|
|
160
|
+
it 'returns top rewards by subjective_value' do
|
|
161
|
+
engine.create_reward(label: 'low', amount: 0.1, delay: 100.0, discount_rate: 1.0)
|
|
162
|
+
engine.create_reward(label: 'high', amount: 0.9, delay: 0.1, discount_rate: 0.01)
|
|
163
|
+
result = engine.most_valuable(limit: 1)
|
|
164
|
+
expect(result.first.label).to eq('high')
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
it 'limits the result set' do
|
|
168
|
+
5.times { |i| engine.create_reward(label: "r#{i}", amount: 0.5, delay: i.to_f + 1.0) }
|
|
169
|
+
expect(engine.most_valuable(limit: 3).size).to eq(3)
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
describe '#prune_expired' do
|
|
174
|
+
it 'removes rewards below min_value threshold' do
|
|
175
|
+
engine.create_reward(label: 'dead', amount: 0.05, delay: 1000.0, discount_rate: 1.0)
|
|
176
|
+
engine.create_reward(label: 'alive', amount: 0.9, delay: 0.1, discount_rate: 0.01)
|
|
177
|
+
result = engine.prune_expired(min_value: 0.05)
|
|
178
|
+
expect(result[:pruned]).to be >= 1
|
|
179
|
+
expect(result[:remaining]).to be >= 1
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
describe '#to_h' do
|
|
184
|
+
it 'returns engine stats hash' do
|
|
185
|
+
engine.create_reward(label: 'x', amount: 0.5, delay: 1.0)
|
|
186
|
+
h = engine.to_h
|
|
187
|
+
expect(h[:total_rewards]).to eq(1)
|
|
188
|
+
expect(h[:domain_rates]).to be_a(Hash)
|
|
189
|
+
expect(h[:patience]).to be_a(Hash)
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/temporal_discounting/client'
|
|
4
|
+
|
|
5
|
+
RSpec.describe Legion::Extensions::TemporalDiscounting::Helpers::Reward do
|
|
6
|
+
subject(:reward) do
|
|
7
|
+
described_class.new(label: 'test', amount: 0.8, delay: 5.0, domain: :financial, discount_rate: 0.1)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
describe '#initialize' do
|
|
11
|
+
it 'assigns a UUID id' do
|
|
12
|
+
expect(reward.id).to match(/\A[0-9a-f-]{36}\z/)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
it 'stores the label' do
|
|
16
|
+
expect(reward.label).to eq('test')
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
it 'clamps amount to [0, 1]' do
|
|
20
|
+
r = described_class.new(label: 'x', amount: 1.5, delay: 1.0)
|
|
21
|
+
expect(r.amount).to eq(1.0)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
it 'clamps amount below 0 to 0' do
|
|
25
|
+
r = described_class.new(label: 'x', amount: -0.5, delay: 1.0)
|
|
26
|
+
expect(r.amount).to eq(0.0)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
it 'clamps discount_rate to MIN' do
|
|
30
|
+
r = described_class.new(label: 'x', amount: 0.5, delay: 1.0, discount_rate: 0.0)
|
|
31
|
+
expect(r.discount_rate).to eq(Legion::Extensions::TemporalDiscounting::Helpers::MIN_DISCOUNT_RATE)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
it 'clamps discount_rate to MAX' do
|
|
35
|
+
r = described_class.new(label: 'x', amount: 0.5, delay: 1.0, discount_rate: 2.0)
|
|
36
|
+
expect(r.discount_rate).to eq(Legion::Extensions::TemporalDiscounting::Helpers::MAX_DISCOUNT_RATE)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
it 'records created_at' do
|
|
40
|
+
expect(reward.created_at).to be_a(Time)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
describe '#subjective_value' do
|
|
45
|
+
it 'computes hyperbolic discount formula V = A / (1 + k*D)' do
|
|
46
|
+
# A=0.8, k=0.1, D=5.0 => 0.8 / (1 + 0.5) = 0.8 / 1.5
|
|
47
|
+
expected = 0.8 / (1.0 + (0.1 * 5.0))
|
|
48
|
+
expect(reward.subjective_value).to be_within(1e-9).of(expected)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
it 'equals amount when delay is 0' do
|
|
52
|
+
r = described_class.new(label: 'x', amount: 0.6, delay: 0.0)
|
|
53
|
+
expect(r.subjective_value).to be_within(1e-9).of(0.6)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
it 'approaches zero as delay grows large' do
|
|
57
|
+
r = described_class.new(label: 'x', amount: 1.0, delay: 10_000.0, discount_rate: 1.0)
|
|
58
|
+
expect(r.subjective_value).to be < 0.001
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
it 'is lower with higher discount rate' do
|
|
62
|
+
r_low = described_class.new(label: 'x', amount: 0.9, delay: 10.0, discount_rate: 0.05)
|
|
63
|
+
r_high = described_class.new(label: 'x', amount: 0.9, delay: 10.0, discount_rate: 0.5)
|
|
64
|
+
expect(r_low.subjective_value).to be > r_high.subjective_value
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
describe '#value_ratio' do
|
|
69
|
+
it 'returns subjective_value / amount' do
|
|
70
|
+
expected = reward.subjective_value / reward.amount
|
|
71
|
+
expect(reward.value_ratio).to be_within(1e-9).of(expected)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
it 'returns 0.0 when amount is 0' do
|
|
75
|
+
r = described_class.new(label: 'x', amount: 0.0, delay: 1.0)
|
|
76
|
+
expect(r.value_ratio).to eq(0.0)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
it 'is 1.0 when delay is 0' do
|
|
80
|
+
r = described_class.new(label: 'x', amount: 0.7, delay: 0.0)
|
|
81
|
+
expect(r.value_ratio).to be_within(1e-9).of(1.0)
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
describe '#value_label' do
|
|
86
|
+
it 'returns :full_value when ratio >= 0.8' do
|
|
87
|
+
r = described_class.new(label: 'x', amount: 0.9, delay: 0.0)
|
|
88
|
+
expect(r.value_label).to eq(:full_value)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
it 'returns :negligible when ratio < 0.2' do
|
|
92
|
+
r = described_class.new(label: 'x', amount: 0.5, delay: 100.0, discount_rate: 1.0)
|
|
93
|
+
expect(r.value_label).to eq(:negligible)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
it 'returns :moderate_value for mid-range ratio' do
|
|
97
|
+
# A=0.5, k=0.1, D=10 => sv = 0.5/2 = 0.25, ratio = 0.5 => moderate_value
|
|
98
|
+
r = described_class.new(label: 'x', amount: 0.5, delay: 10.0, discount_rate: 0.1)
|
|
99
|
+
ratio = r.value_ratio
|
|
100
|
+
expect(ratio).to be_between(0.4, 0.6)
|
|
101
|
+
expect(r.value_label).to eq(:moderate_value)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
describe '#impulsivity_label' do
|
|
106
|
+
it 'returns :patient for k < 0.05' do
|
|
107
|
+
r = described_class.new(label: 'x', amount: 0.5, delay: 1.0, discount_rate: 0.02)
|
|
108
|
+
expect(r.impulsivity_label).to eq(:patient)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
it 'returns :moderate for k in [0.05, 0.15)' do
|
|
112
|
+
r = described_class.new(label: 'x', amount: 0.5, delay: 1.0, discount_rate: 0.1)
|
|
113
|
+
expect(r.impulsivity_label).to eq(:moderate)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
it 'returns :impulsive for k in [0.15, 0.3)' do
|
|
117
|
+
r = described_class.new(label: 'x', amount: 0.5, delay: 1.0, discount_rate: 0.2)
|
|
118
|
+
expect(r.impulsivity_label).to eq(:impulsive)
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
it 'returns :very_impulsive for k in [0.3, 0.6)' do
|
|
122
|
+
r = described_class.new(label: 'x', amount: 0.5, delay: 1.0, discount_rate: 0.4)
|
|
123
|
+
expect(r.impulsivity_label).to eq(:very_impulsive)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
it 'returns :extreme for k in [0.6, 1.0]' do
|
|
127
|
+
r = described_class.new(label: 'x', amount: 0.5, delay: 1.0, discount_rate: 0.8)
|
|
128
|
+
expect(r.impulsivity_label).to eq(:extreme)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
describe '#worth_waiting?' do
|
|
133
|
+
it 'returns true when subjective_value >= default threshold 0.3' do
|
|
134
|
+
r = described_class.new(label: 'x', amount: 0.9, delay: 0.5, discount_rate: 0.1)
|
|
135
|
+
expect(r.worth_waiting?).to be true
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
it 'returns false when subjective_value < threshold' do
|
|
139
|
+
r = described_class.new(label: 'x', amount: 0.1, delay: 100.0, discount_rate: 1.0)
|
|
140
|
+
expect(r.worth_waiting?).to be false
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
it 'accepts custom threshold' do
|
|
144
|
+
r = described_class.new(label: 'x', amount: 0.8, delay: 2.0, discount_rate: 0.1)
|
|
145
|
+
sv = r.subjective_value
|
|
146
|
+
expect(r.worth_waiting?(threshold: sv - 0.01)).to be true
|
|
147
|
+
expect(r.worth_waiting?(threshold: sv + 0.01)).to be false
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
describe '#adjust_delay!' do
|
|
152
|
+
it 'updates delay and changes subjective_value' do
|
|
153
|
+
original_sv = reward.subjective_value
|
|
154
|
+
reward.adjust_delay!(new_delay: 50.0)
|
|
155
|
+
expect(reward.delay).to eq(50.0)
|
|
156
|
+
expect(reward.subjective_value).to be < original_sv
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
describe '#adjust_discount_rate!' do
|
|
161
|
+
it 'updates discount_rate clamped to [MIN, MAX]' do
|
|
162
|
+
reward.adjust_discount_rate!(new_rate: 0.5)
|
|
163
|
+
expect(reward.discount_rate).to eq(0.5)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
it 'clamps below MIN_DISCOUNT_RATE' do
|
|
167
|
+
reward.adjust_discount_rate!(new_rate: 0.0)
|
|
168
|
+
expect(reward.discount_rate).to eq(Legion::Extensions::TemporalDiscounting::Helpers::MIN_DISCOUNT_RATE)
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
describe '#to_h' do
|
|
173
|
+
it 'returns a complete hash' do
|
|
174
|
+
h = reward.to_h
|
|
175
|
+
expect(h[:id]).to eq(reward.id)
|
|
176
|
+
expect(h[:label]).to eq('test')
|
|
177
|
+
expect(h[:amount]).to eq(0.8)
|
|
178
|
+
expect(h[:delay]).to eq(5.0)
|
|
179
|
+
expect(h[:domain]).to eq(:financial)
|
|
180
|
+
expect(h[:discount_rate]).to eq(0.1)
|
|
181
|
+
expect(h[:subjective_value]).to be_a(Float)
|
|
182
|
+
expect(h[:value_ratio]).to be_a(Float)
|
|
183
|
+
expect(h[:value_label]).to be_a(Symbol)
|
|
184
|
+
expect(h[:impulsivity_label]).to be_a(Symbol)
|
|
185
|
+
expect(h[:worth_waiting]).to be(true).or be(false)
|
|
186
|
+
expect(h[:created_at]).to be_a(Time)
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/temporal_discounting/client'
|
|
4
|
+
|
|
5
|
+
RSpec.describe Legion::Extensions::TemporalDiscounting::Runners::TemporalDiscounting do
|
|
6
|
+
let(:client) { Legion::Extensions::TemporalDiscounting::Client.new }
|
|
7
|
+
|
|
8
|
+
describe '#create_temporal_reward' do
|
|
9
|
+
it 'creates a reward and returns success' do
|
|
10
|
+
result = client.create_temporal_reward(label: 'test', amount: 0.7, delay: 5.0)
|
|
11
|
+
expect(result[:success]).to be true
|
|
12
|
+
expect(result[:reward][:id]).to match(/\A[0-9a-f-]{36}\z/)
|
|
13
|
+
expect(result[:reward][:label]).to eq('test')
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
it 'stores the reward in the engine' do
|
|
17
|
+
result = client.create_temporal_reward(label: 'x', amount: 0.5, delay: 2.0)
|
|
18
|
+
id = result[:reward][:id]
|
|
19
|
+
expect(client.engine.rewards).to have_key(id)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
it 'passes domain and discount_rate through' do
|
|
23
|
+
result = client.create_temporal_reward(
|
|
24
|
+
label: 'fin', amount: 0.8, delay: 10.0, domain: :financial, discount_rate: 0.25
|
|
25
|
+
)
|
|
26
|
+
expect(result[:reward][:domain]).to eq(:financial)
|
|
27
|
+
expect(result[:reward][:discount_rate]).to eq(0.25)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
describe '#compare_temporal_rewards' do
|
|
32
|
+
it 'compares two rewards' do
|
|
33
|
+
a = client.create_temporal_reward(label: 'a', amount: 0.9, delay: 1.0, discount_rate: 0.1)
|
|
34
|
+
b = client.create_temporal_reward(label: 'b', amount: 0.2, delay: 1.0, discount_rate: 0.1)
|
|
35
|
+
result = client.compare_temporal_rewards(reward_a_id: a[:reward][:id], reward_b_id: b[:reward][:id])
|
|
36
|
+
expect(result[:success]).to be true
|
|
37
|
+
expect(result[:preferred]).to eq(a[:reward][:id])
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
it 'returns not_found error for missing reward' do
|
|
41
|
+
r = client.create_temporal_reward(label: 'x', amount: 0.5, delay: 1.0)
|
|
42
|
+
result = client.compare_temporal_rewards(reward_a_id: r[:reward][:id], reward_b_id: 'missing')
|
|
43
|
+
expect(result[:error]).to eq(:not_found)
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
describe '#check_worth_waiting' do
|
|
48
|
+
it 'returns worth_waiting true for high-value reward' do
|
|
49
|
+
r = client.create_temporal_reward(label: 'x', amount: 0.9, delay: 0.5, discount_rate: 0.1)
|
|
50
|
+
result = client.check_worth_waiting(reward_id: r[:reward][:id])
|
|
51
|
+
expect(result[:success]).to be true
|
|
52
|
+
expect(result[:worth_waiting]).to be true
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
it 'returns not_found error for missing id' do
|
|
56
|
+
result = client.check_worth_waiting(reward_id: 'missing')
|
|
57
|
+
expect(result[:success]).to be false
|
|
58
|
+
expect(result[:error]).to eq(:not_found)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
describe '#immediate_vs_delayed_comparison' do
|
|
63
|
+
it 'returns preferred option' do
|
|
64
|
+
result = client.immediate_vs_delayed_comparison(
|
|
65
|
+
immediate_amount: 0.8, delayed_amount: 0.9, delay: 100.0
|
|
66
|
+
)
|
|
67
|
+
expect(result[:success]).to be true
|
|
68
|
+
expect(result[:preferred]).to be_a(Symbol)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
it 'includes discount_rate in result' do
|
|
72
|
+
result = client.immediate_vs_delayed_comparison(
|
|
73
|
+
immediate_amount: 0.3, delayed_amount: 0.9, delay: 5.0
|
|
74
|
+
)
|
|
75
|
+
expect(result[:discount_rate]).to be_a(Float)
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
describe '#compute_optimal_delay' do
|
|
80
|
+
it 'returns max_delay for a given min_value' do
|
|
81
|
+
r = client.create_temporal_reward(label: 'x', amount: 0.8, delay: 1.0, discount_rate: 0.1)
|
|
82
|
+
result = client.compute_optimal_delay(reward_id: r[:reward][:id], min_value: 0.4)
|
|
83
|
+
expect(result[:success]).to be true
|
|
84
|
+
expect(result[:max_delay]).to be_within(1e-9).of(10.0)
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
it 'returns not_found for missing reward' do
|
|
88
|
+
result = client.compute_optimal_delay(reward_id: 'none')
|
|
89
|
+
expect(result[:success]).to be false
|
|
90
|
+
expect(result[:error]).to eq(:not_found)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
describe '#temporal_patience_report' do
|
|
95
|
+
it 'returns report with success' do
|
|
96
|
+
client.create_temporal_reward(label: 'a', amount: 0.8, delay: 2.0, discount_rate: 0.1)
|
|
97
|
+
result = client.temporal_patience_report
|
|
98
|
+
expect(result[:success]).to be true
|
|
99
|
+
expect(result[:total_rewards]).to eq(1)
|
|
100
|
+
expect(result[:avg_discount_rate]).to be_a(Float)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
describe '#set_domain_discount_rate' do
|
|
105
|
+
it 'sets and confirms domain rate' do
|
|
106
|
+
result = client.set_domain_discount_rate(domain: :health, rate: 0.2)
|
|
107
|
+
expect(result[:success]).to be true
|
|
108
|
+
expect(result[:domain]).to eq(:health)
|
|
109
|
+
expect(result[:rate]).to eq(0.2)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
describe '#most_valuable_rewards' do
|
|
114
|
+
it 'returns top rewards by subjective_value' do
|
|
115
|
+
client.create_temporal_reward(label: 'low', amount: 0.1, delay: 100.0, discount_rate: 1.0)
|
|
116
|
+
client.create_temporal_reward(label: 'high', amount: 0.9, delay: 0.0, discount_rate: 0.1)
|
|
117
|
+
result = client.most_valuable_rewards(limit: 1)
|
|
118
|
+
expect(result[:success]).to be true
|
|
119
|
+
expect(result[:rewards].first[:label]).to eq('high')
|
|
120
|
+
expect(result[:count]).to eq(1)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
describe '#update_temporal_discounting' do
|
|
125
|
+
it 'prunes low-value rewards and returns stats' do
|
|
126
|
+
client.create_temporal_reward(label: 'dead', amount: 0.02, delay: 1000.0, discount_rate: 1.0)
|
|
127
|
+
client.create_temporal_reward(label: 'alive', amount: 0.9, delay: 0.1, discount_rate: 0.01)
|
|
128
|
+
result = client.update_temporal_discounting(min_value: 0.05)
|
|
129
|
+
expect(result[:success]).to be true
|
|
130
|
+
expect(result[:pruned]).to be >= 1
|
|
131
|
+
expect(result[:stats]).to be_a(Hash)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
describe '#temporal_discounting_stats' do
|
|
136
|
+
it 'returns engine stats' do
|
|
137
|
+
client.create_temporal_reward(label: 'x', amount: 0.5, delay: 1.0)
|
|
138
|
+
result = client.temporal_discounting_stats
|
|
139
|
+
expect(result[:success]).to be true
|
|
140
|
+
expect(result[:total_rewards]).to eq(1)
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
data/spec/spec_helper.rb
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'bundler/setup'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module Logging
|
|
7
|
+
def self.debug(_msg); end
|
|
8
|
+
|
|
9
|
+
def self.info(_msg); end
|
|
10
|
+
|
|
11
|
+
def self.warn(_msg); end
|
|
12
|
+
|
|
13
|
+
def self.error(_msg); end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
require 'legion/extensions/temporal_discounting'
|
|
18
|
+
|
|
19
|
+
RSpec.configure do |config|
|
|
20
|
+
config.example_status_persistence_file_path = '.rspec_status'
|
|
21
|
+
config.disable_monkey_patching!
|
|
22
|
+
config.expect_with(:rspec) { |c| c.syntax = :expect }
|
|
23
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: lex-temporal-discounting
|
|
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: Hyperbolic temporal discounting model for brain-modeled agentic AI planning
|
|
27
|
+
and impulse control
|
|
28
|
+
email:
|
|
29
|
+
- matthewdiverson@gmail.com
|
|
30
|
+
executables: []
|
|
31
|
+
extensions: []
|
|
32
|
+
extra_rdoc_files: []
|
|
33
|
+
files:
|
|
34
|
+
- Gemfile
|
|
35
|
+
- lex-temporal-discounting.gemspec
|
|
36
|
+
- lib/legion/extensions/temporal_discounting.rb
|
|
37
|
+
- lib/legion/extensions/temporal_discounting/client.rb
|
|
38
|
+
- lib/legion/extensions/temporal_discounting/helpers/constants.rb
|
|
39
|
+
- lib/legion/extensions/temporal_discounting/helpers/discounting_engine.rb
|
|
40
|
+
- lib/legion/extensions/temporal_discounting/helpers/reward.rb
|
|
41
|
+
- lib/legion/extensions/temporal_discounting/runners/temporal_discounting.rb
|
|
42
|
+
- lib/legion/extensions/temporal_discounting/version.rb
|
|
43
|
+
- spec/legion/extensions/temporal_discounting/client_spec.rb
|
|
44
|
+
- spec/legion/extensions/temporal_discounting/helpers/discounting_engine_spec.rb
|
|
45
|
+
- spec/legion/extensions/temporal_discounting/helpers/reward_spec.rb
|
|
46
|
+
- spec/legion/extensions/temporal_discounting/runners/temporal_discounting_spec.rb
|
|
47
|
+
- spec/spec_helper.rb
|
|
48
|
+
homepage: https://github.com/LegionIO/lex-temporal-discounting
|
|
49
|
+
licenses:
|
|
50
|
+
- MIT
|
|
51
|
+
metadata:
|
|
52
|
+
homepage_uri: https://github.com/LegionIO/lex-temporal-discounting
|
|
53
|
+
source_code_uri: https://github.com/LegionIO/lex-temporal-discounting
|
|
54
|
+
documentation_uri: https://github.com/LegionIO/lex-temporal-discounting
|
|
55
|
+
changelog_uri: https://github.com/LegionIO/lex-temporal-discounting
|
|
56
|
+
bug_tracker_uri: https://github.com/LegionIO/lex-temporal-discounting/issues
|
|
57
|
+
rubygems_mfa_required: 'true'
|
|
58
|
+
rdoc_options: []
|
|
59
|
+
require_paths:
|
|
60
|
+
- lib
|
|
61
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
62
|
+
requirements:
|
|
63
|
+
- - ">="
|
|
64
|
+
- !ruby/object:Gem::Version
|
|
65
|
+
version: '3.4'
|
|
66
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
67
|
+
requirements:
|
|
68
|
+
- - ">="
|
|
69
|
+
- !ruby/object:Gem::Version
|
|
70
|
+
version: '0'
|
|
71
|
+
requirements: []
|
|
72
|
+
rubygems_version: 3.6.9
|
|
73
|
+
specification_version: 4
|
|
74
|
+
summary: LEX Temporal Discounting
|
|
75
|
+
test_files: []
|