lex-dual-process 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 +15 -0
- data/LICENSE +21 -0
- data/README.md +67 -0
- data/lex-dual-process.gemspec +29 -0
- data/lib/legion/extensions/dual_process/client.rb +21 -0
- data/lib/legion/extensions/dual_process/helpers/constants.rb +40 -0
- data/lib/legion/extensions/dual_process/helpers/decision.rb +46 -0
- data/lib/legion/extensions/dual_process/helpers/dual_process_engine.rb +196 -0
- data/lib/legion/extensions/dual_process/helpers/heuristic.rb +63 -0
- data/lib/legion/extensions/dual_process/runners/dual_process.rb +83 -0
- data/lib/legion/extensions/dual_process/version.rb +9 -0
- data/lib/legion/extensions/dual_process.rb +16 -0
- data/spec/legion/extensions/dual_process/client_spec.rb +74 -0
- data/spec/legion/extensions/dual_process/helpers/constants_spec.rb +55 -0
- data/spec/legion/extensions/dual_process/helpers/decision_spec.rb +76 -0
- data/spec/legion/extensions/dual_process/helpers/dual_process_engine_spec.rb +274 -0
- data/spec/legion/extensions/dual_process/helpers/heuristic_spec.rb +144 -0
- data/spec/legion/extensions/dual_process/runners/dual_process_spec.rb +188 -0
- data/spec/spec_helper.rb +24 -0
- metadata +80 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/dual_process/client'
|
|
4
|
+
|
|
5
|
+
RSpec.describe Legion::Extensions::DualProcess::Client do
|
|
6
|
+
let(:client) { described_class.new }
|
|
7
|
+
|
|
8
|
+
it 'responds to all runner methods' do
|
|
9
|
+
expect(client).to respond_to(:register_heuristic)
|
|
10
|
+
expect(client).to respond_to(:route_decision)
|
|
11
|
+
expect(client).to respond_to(:execute_system_one)
|
|
12
|
+
expect(client).to respond_to(:execute_system_two)
|
|
13
|
+
expect(client).to respond_to(:record_decision_outcome)
|
|
14
|
+
expect(client).to respond_to(:effort_assessment)
|
|
15
|
+
expect(client).to respond_to(:best_heuristics)
|
|
16
|
+
expect(client).to respond_to(:system_usage_stats)
|
|
17
|
+
expect(client).to respond_to(:update_dual_process)
|
|
18
|
+
expect(client).to respond_to(:dual_process_stats)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
it 'accepts an injected engine' do
|
|
22
|
+
custom_engine = Legion::Extensions::DualProcess::Helpers::DualProcessEngine.new
|
|
23
|
+
c = described_class.new(engine: custom_engine)
|
|
24
|
+
expect(c.send(:engine)).to be(custom_engine)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
it 'creates its own engine when none injected' do
|
|
28
|
+
expect(client.send(:engine)).to be_a(Legion::Extensions::DualProcess::Helpers::DualProcessEngine)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
it 'runs a full System 1 decision cycle' do
|
|
32
|
+
client.register_heuristic(pattern: 'greet', domain: :social, response: :smile, confidence: 0.9)
|
|
33
|
+
route = client.route_decision(query: 'greet friend', domain: :social, complexity: 0.2)
|
|
34
|
+
expect(route[:system]).to eq(:system_one)
|
|
35
|
+
|
|
36
|
+
exec = client.execute_system_one(query: 'greet friend', domain: :social)
|
|
37
|
+
expect(exec[:success]).to be true
|
|
38
|
+
expect(exec[:response]).to eq(:smile)
|
|
39
|
+
|
|
40
|
+
outcome = client.record_decision_outcome(decision_id: exec[:decision_id], outcome: :correct)
|
|
41
|
+
expect(outcome[:success]).to be true
|
|
42
|
+
|
|
43
|
+
stats = client.dual_process_stats
|
|
44
|
+
expect(stats[:system_stats][:system_one]).to eq(1)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
it 'runs a full System 2 decision cycle' do
|
|
48
|
+
route = client.route_decision(query: 'novel complex problem', domain: :analysis, complexity: 0.9)
|
|
49
|
+
expect(route[:system]).to eq(:system_two)
|
|
50
|
+
|
|
51
|
+
exec = client.execute_system_two(
|
|
52
|
+
query: 'novel complex problem', domain: :analysis,
|
|
53
|
+
deliberation: { confidence: 0.88, response: :thorough_analysis }
|
|
54
|
+
)
|
|
55
|
+
expect(exec[:success]).to be true
|
|
56
|
+
expect(exec[:response]).to eq(:thorough_analysis)
|
|
57
|
+
|
|
58
|
+
stats = client.dual_process_stats
|
|
59
|
+
expect(stats[:system_stats][:system_two]).to eq(1)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
it 'tracks effort depletion and recovery' do
|
|
63
|
+
initial = client.effort_assessment[:effort_level]
|
|
64
|
+
expect(initial).to eq(1.0)
|
|
65
|
+
|
|
66
|
+
5.times { client.execute_system_two(query: 'x', domain: :d) }
|
|
67
|
+
after_work = client.effort_assessment[:effort_level]
|
|
68
|
+
expect(after_work).to be < initial
|
|
69
|
+
|
|
70
|
+
client.update_dual_process
|
|
71
|
+
after_recovery = client.effort_assessment[:effort_level]
|
|
72
|
+
expect(after_recovery).to be > after_work
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe Legion::Extensions::DualProcess::Helpers::Constants do
|
|
4
|
+
it 'defines MAX_DECISIONS' do
|
|
5
|
+
expect(described_class::MAX_DECISIONS).to eq(200)
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
it 'defines MAX_HEURISTICS' do
|
|
9
|
+
expect(described_class::MAX_HEURISTICS).to eq(100)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
it 'defines MAX_HISTORY' do
|
|
13
|
+
expect(described_class::MAX_HISTORY).to eq(300)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
it 'defines confidence bounds' do
|
|
17
|
+
expect(described_class::CONFIDENCE_FLOOR).to eq(0.05)
|
|
18
|
+
expect(described_class::CONFIDENCE_CEILING).to eq(0.95)
|
|
19
|
+
expect(described_class::DEFAULT_CONFIDENCE).to eq(0.5)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
it 'defines routing thresholds' do
|
|
23
|
+
expect(described_class::SYSTEM_ONE_THRESHOLD).to eq(0.6)
|
|
24
|
+
expect(described_class::COMPLEXITY_THRESHOLD).to eq(0.5)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
it 'defines effort constants' do
|
|
28
|
+
expect(described_class::EFFORT_COST).to eq(0.1)
|
|
29
|
+
expect(described_class::EFFORT_RECOVERY_RATE).to eq(0.05)
|
|
30
|
+
expect(described_class::MAX_EFFORT_BUDGET).to eq(1.0)
|
|
31
|
+
expect(described_class::FATIGUE_PENALTY).to eq(0.15)
|
|
32
|
+
expect(described_class::HEURISTIC_BOOST).to eq(0.2)
|
|
33
|
+
expect(described_class::DECAY_RATE).to eq(0.01)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
it 'defines SYSTEMS' do
|
|
37
|
+
expect(described_class::SYSTEMS).to contain_exactly(:system_one, :system_two)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
it 'defines ROUTING_LABELS as a hash of ranges' do
|
|
41
|
+
labels = described_class::ROUTING_LABELS.values
|
|
42
|
+
expect(labels).to include(:automatic, :fluent, :effortful, :strained, :depleted)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
it 'covers the full 0..1 range in ROUTING_LABELS' do
|
|
46
|
+
labels = described_class::ROUTING_LABELS
|
|
47
|
+
expect(labels.any? { |range, _| range.cover?(0.0) }).to be true
|
|
48
|
+
expect(labels.any? { |range, _| range.cover?(1.0) }).to be true
|
|
49
|
+
expect(labels.any? { |range, _| range.cover?(0.5) }).to be true
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
it 'defines DECISION_OUTCOMES' do
|
|
53
|
+
expect(described_class::DECISION_OUTCOMES).to contain_exactly(:correct, :incorrect, :uncertain)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe Legion::Extensions::DualProcess::Helpers::Decision do
|
|
4
|
+
let(:decision) do
|
|
5
|
+
described_class.new(
|
|
6
|
+
query: 'should I wave?',
|
|
7
|
+
domain: :social,
|
|
8
|
+
system_used: :system_one,
|
|
9
|
+
confidence: 0.75,
|
|
10
|
+
complexity: 0.3
|
|
11
|
+
)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
describe '#initialize' do
|
|
15
|
+
it 'assigns a uuid id' do
|
|
16
|
+
expect(decision.id).to match(/\A[0-9a-f-]{36}\z/)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
it 'stores core fields' do
|
|
20
|
+
expect(decision.query).to eq('should I wave?')
|
|
21
|
+
expect(decision.domain).to eq(:social)
|
|
22
|
+
expect(decision.system_used).to eq(:system_one)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
it 'clamps confidence to valid range' do
|
|
26
|
+
d = described_class.new(query: 'x', domain: :d, system_used: :system_one, confidence: 2.0, complexity: 0.5)
|
|
27
|
+
expect(d.confidence).to eq(Legion::Extensions::DualProcess::Helpers::Constants::CONFIDENCE_CEILING)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
it 'clamps complexity to [0, 1]' do
|
|
31
|
+
d = described_class.new(query: 'x', domain: :d, system_used: :system_one, confidence: 0.5, complexity: -0.5)
|
|
32
|
+
expect(d.complexity).to eq(0.0)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
it 'starts with nil outcome' do
|
|
36
|
+
expect(decision.outcome).to be_nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
it 'defaults effort_cost to 0.0' do
|
|
40
|
+
expect(decision.effort_cost).to eq(0.0)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
it 'sets created_at' do
|
|
44
|
+
expect(decision.created_at).to be_a(Time)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
it 'accepts optional heuristic_id' do
|
|
48
|
+
d = described_class.new(
|
|
49
|
+
query: 'x', domain: :d, system_used: :system_one,
|
|
50
|
+
confidence: 0.5, complexity: 0.2, heuristic_id: 'abc-123'
|
|
51
|
+
)
|
|
52
|
+
expect(d.heuristic_id).to eq('abc-123')
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
describe '#outcome=' do
|
|
57
|
+
it 'sets the outcome' do
|
|
58
|
+
decision.outcome = :correct
|
|
59
|
+
expect(decision.outcome).to eq(:correct)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
describe '#to_h' do
|
|
64
|
+
it 'includes all key fields' do
|
|
65
|
+
result = decision.to_h
|
|
66
|
+
expect(result).to include(:id, :query, :domain, :system_used, :confidence,
|
|
67
|
+
:complexity, :heuristic_id, :outcome, :effort_cost,
|
|
68
|
+
:processing_time_ms, :created_at)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
it 'reflects outcome after setting it' do
|
|
72
|
+
decision.outcome = :incorrect
|
|
73
|
+
expect(decision.to_h[:outcome]).to eq(:incorrect)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe Legion::Extensions::DualProcess::Helpers::DualProcessEngine do
|
|
4
|
+
subject(:engine) { described_class.new }
|
|
5
|
+
|
|
6
|
+
describe '#initialize' do
|
|
7
|
+
it 'starts with full effort budget' do
|
|
8
|
+
expect(engine.effort_level).to eq(1.0)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
it 'starts with no heuristics' do
|
|
12
|
+
expect(engine.system_stats[:total]).to eq(0)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
describe '#register_heuristic' do
|
|
17
|
+
it 'returns a Heuristic object' do
|
|
18
|
+
h = engine.register_heuristic(pattern: 'hello', domain: :social, response: :wave)
|
|
19
|
+
expect(h).to be_a(Legion::Extensions::DualProcess::Helpers::Heuristic)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
it 'stores the heuristic' do
|
|
23
|
+
engine.register_heuristic(pattern: 'hello', domain: :social, response: :wave)
|
|
24
|
+
expect(engine.to_h[:heuristic_count]).to eq(1)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
it 'accepts custom confidence' do
|
|
28
|
+
h = engine.register_heuristic(pattern: :x, domain: :d, response: :r, confidence: 0.8)
|
|
29
|
+
expect(h.confidence).to eq(0.8)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
it 'evicts oldest when at capacity' do
|
|
33
|
+
stub_const('Legion::Extensions::DualProcess::Helpers::Constants::MAX_HEURISTICS', 2)
|
|
34
|
+
h1 = engine.register_heuristic(pattern: 'a', domain: :d, response: :r1)
|
|
35
|
+
h2 = engine.register_heuristic(pattern: 'b', domain: :d, response: :r2)
|
|
36
|
+
engine.register_heuristic(pattern: 'c', domain: :d, response: :r3)
|
|
37
|
+
ids = [h1.id, h2.id]
|
|
38
|
+
expect(engine.to_h[:heuristic_count]).to eq(2)
|
|
39
|
+
# one of the originals was evicted
|
|
40
|
+
expect(ids.count { |id| engine.instance_variable_get(:@heuristics).key?(id) }).to be <= 1
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
describe '#route_decision' do
|
|
45
|
+
context 'with a matching confident heuristic and low complexity' do
|
|
46
|
+
before do
|
|
47
|
+
engine.register_heuristic(pattern: 'hello', domain: :social, response: :wave, confidence: 0.8)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
it 'routes to system_one' do
|
|
51
|
+
result = engine.route_decision(query: 'say hello', domain: :social, complexity: 0.2)
|
|
52
|
+
expect(result[:system]).to eq(:system_one)
|
|
53
|
+
expect(result[:reason]).to eq(:heuristic_match)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
context 'with high complexity' do
|
|
58
|
+
it 'routes to system_two' do
|
|
59
|
+
result = engine.route_decision(query: 'complex decision', domain: :work, complexity: 0.8)
|
|
60
|
+
expect(result[:system]).to eq(:system_two)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
context 'with no matching heuristic and low complexity' do
|
|
65
|
+
it 'routes to system_two due to no heuristic' do
|
|
66
|
+
result = engine.route_decision(query: 'unknown query', domain: :work, complexity: 0.3)
|
|
67
|
+
expect(result[:system]).to eq(:system_two)
|
|
68
|
+
expect(result[:reason]).to eq(:no_heuristic)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
context 'when effort is depleted' do
|
|
73
|
+
before do
|
|
74
|
+
stub_const('Legion::Extensions::DualProcess::Helpers::Constants::MAX_EFFORT_BUDGET', 0.0)
|
|
75
|
+
engine.instance_variable_set(:@effort_budget, 0.0)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
it 'forces system_one with fatigue flag' do
|
|
79
|
+
result = engine.route_decision(query: 'complex', domain: :work, complexity: 0.9)
|
|
80
|
+
expect(result[:system]).to eq(:system_one)
|
|
81
|
+
expect(result[:reason]).to eq(:effort_depleted)
|
|
82
|
+
expect(result[:fatigue]).to be true
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
describe '#execute_system_one' do
|
|
88
|
+
context 'with a matching heuristic' do
|
|
89
|
+
before do
|
|
90
|
+
engine.register_heuristic(pattern: 'hello', domain: :social, response: :wave, confidence: 0.8)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
it 'returns the heuristic response' do
|
|
94
|
+
result = engine.execute_system_one(query: 'say hello', domain: :social)
|
|
95
|
+
expect(result[:response]).to eq(:wave)
|
|
96
|
+
expect(result[:system]).to eq(:system_one)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
it 'includes decision_id' do
|
|
100
|
+
result = engine.execute_system_one(query: 'say hello', domain: :social)
|
|
101
|
+
expect(result[:decision_id]).to match(/\A[0-9a-f-]{36}\z/)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
context 'without a matching heuristic' do
|
|
106
|
+
it 'returns :no_heuristic response' do
|
|
107
|
+
result = engine.execute_system_one(query: 'unknown', domain: :work)
|
|
108
|
+
expect(result[:response]).to eq(:no_heuristic)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
it 'applies fatigue penalty to confidence' do
|
|
112
|
+
result = engine.execute_system_one(query: 'unknown', domain: :work)
|
|
113
|
+
expected = (Legion::Extensions::DualProcess::Helpers::Constants::DEFAULT_CONFIDENCE -
|
|
114
|
+
Legion::Extensions::DualProcess::Helpers::Constants::FATIGUE_PENALTY)
|
|
115
|
+
expect(result[:confidence]).to be_within(0.001).of(expected)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
describe '#execute_system_two' do
|
|
121
|
+
it 'deducts effort from the budget' do
|
|
122
|
+
before_effort = engine.effort_level
|
|
123
|
+
engine.execute_system_two(query: 'complex', domain: :work)
|
|
124
|
+
expect(engine.effort_level).to be < before_effort
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
it 'returns a decision_id' do
|
|
128
|
+
result = engine.execute_system_two(query: 'complex', domain: :work)
|
|
129
|
+
expect(result[:decision_id]).to match(/\A[0-9a-f-]{36}\z/)
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
it 'returns system_two' do
|
|
133
|
+
result = engine.execute_system_two(query: 'complex', domain: :work)
|
|
134
|
+
expect(result[:system]).to eq(:system_two)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
it 'uses deliberation confidence when provided' do
|
|
138
|
+
result = engine.execute_system_two(query: 'x', domain: :d, deliberation: { confidence: 0.85 })
|
|
139
|
+
expect(result[:confidence]).to eq(0.85)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
it 'uses deliberation response when provided' do
|
|
143
|
+
result = engine.execute_system_two(query: 'x', domain: :d, deliberation: { response: :approve })
|
|
144
|
+
expect(result[:response]).to eq(:approve)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
it 'does not go below zero effort' do
|
|
148
|
+
15.times { engine.execute_system_two(query: 'x', domain: :d) }
|
|
149
|
+
expect(engine.effort_level).to be >= 0.0
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
describe '#record_outcome' do
|
|
154
|
+
let(:decision_id) do
|
|
155
|
+
engine.execute_system_one(query: 'test', domain: :work)[:decision_id]
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
it 'returns success for known decision' do
|
|
159
|
+
result = engine.record_outcome(decision_id: decision_id, outcome: :correct)
|
|
160
|
+
expect(result[:success]).to be true
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
it 'returns not_found for unknown decision' do
|
|
164
|
+
result = engine.record_outcome(decision_id: 'nonexistent', outcome: :correct)
|
|
165
|
+
expect(result[:success]).to be false
|
|
166
|
+
expect(result[:reason]).to eq(:not_found)
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
describe '#effort_level' do
|
|
171
|
+
it 'starts at 1.0' do
|
|
172
|
+
expect(engine.effort_level).to eq(1.0)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
it 'decreases after system_two execution' do
|
|
176
|
+
engine.execute_system_two(query: 'x', domain: :d)
|
|
177
|
+
expect(engine.effort_level).to be < 1.0
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
describe '#routing_label' do
|
|
182
|
+
it 'returns :automatic at full effort' do
|
|
183
|
+
expect(engine.routing_label).to eq(:automatic)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
it 'returns :depleted at very low effort' do
|
|
187
|
+
engine.instance_variable_set(:@effort_budget, 0.1)
|
|
188
|
+
expect(engine.routing_label).to eq(:depleted)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
it 'returns :effortful at mid effort' do
|
|
192
|
+
engine.instance_variable_set(:@effort_budget, 0.5)
|
|
193
|
+
expect(engine.routing_label).to eq(:effortful)
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
describe '#recover_effort' do
|
|
198
|
+
it 'increases effort budget' do
|
|
199
|
+
engine.execute_system_two(query: 'x', domain: :d)
|
|
200
|
+
before = engine.effort_level
|
|
201
|
+
engine.recover_effort
|
|
202
|
+
expect(engine.effort_level).to be > before
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
it 'does not exceed MAX_EFFORT_BUDGET' do
|
|
206
|
+
5.times { engine.recover_effort }
|
|
207
|
+
expect(engine.effort_level).to be <= 1.0
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
describe '#decay_heuristics' do
|
|
212
|
+
it 'reduces confidence of used heuristics' do
|
|
213
|
+
h = engine.register_heuristic(pattern: 'x', domain: :d, response: :r, confidence: 0.8)
|
|
214
|
+
h.use!(success: true)
|
|
215
|
+
before = h.confidence
|
|
216
|
+
engine.decay_heuristics
|
|
217
|
+
expect(h.confidence).to be < before
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
it 'does not decay unused heuristics' do
|
|
221
|
+
h = engine.register_heuristic(pattern: 'x', domain: :d, response: :r, confidence: 0.8)
|
|
222
|
+
before = h.confidence
|
|
223
|
+
engine.decay_heuristics
|
|
224
|
+
expect(h.confidence).to eq(before)
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
describe '#system_stats' do
|
|
229
|
+
it 'returns zeros with no decisions' do
|
|
230
|
+
stats = engine.system_stats
|
|
231
|
+
expect(stats[:total]).to eq(0)
|
|
232
|
+
expect(stats[:s1_ratio]).to eq(0.0)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
it 'counts system_one and system_two decisions separately' do
|
|
236
|
+
engine.execute_system_one(query: 'x', domain: :d)
|
|
237
|
+
engine.execute_system_two(query: 'y', domain: :d)
|
|
238
|
+
stats = engine.system_stats
|
|
239
|
+
expect(stats[:system_one]).to eq(1)
|
|
240
|
+
expect(stats[:system_two]).to eq(1)
|
|
241
|
+
expect(stats[:total]).to eq(2)
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
describe '#best_heuristics' do
|
|
246
|
+
it 'returns empty array with no reliable heuristics' do
|
|
247
|
+
expect(engine.best_heuristics(limit: 5)).to eq([])
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
it 'returns reliable heuristics sorted by success_rate' do
|
|
251
|
+
h = engine.register_heuristic(pattern: 'x', domain: :d, response: :r, confidence: 0.9)
|
|
252
|
+
5.times { h.use!(success: true) }
|
|
253
|
+
result = engine.best_heuristics(limit: 5)
|
|
254
|
+
expect(result.size).to eq(1)
|
|
255
|
+
expect(result.first[:id]).to eq(h.id)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
it 'respects the limit' do
|
|
259
|
+
3.times do |i|
|
|
260
|
+
h = engine.register_heuristic(pattern: "p#{i}", domain: :d, response: :r, confidence: 0.9)
|
|
261
|
+
5.times { h.use!(success: true) }
|
|
262
|
+
end
|
|
263
|
+
expect(engine.best_heuristics(limit: 2).size).to eq(2)
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
describe '#to_h' do
|
|
268
|
+
it 'includes all key fields' do
|
|
269
|
+
result = engine.to_h
|
|
270
|
+
expect(result).to include(:effort_budget, :effort_level, :routing_label,
|
|
271
|
+
:heuristic_count, :decision_count, :system_stats)
|
|
272
|
+
end
|
|
273
|
+
end
|
|
274
|
+
end
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe Legion::Extensions::DualProcess::Helpers::Heuristic do
|
|
4
|
+
let(:heuristic) do
|
|
5
|
+
described_class.new(pattern: 'greeting', domain: :social, response: :wave)
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
describe '#initialize' do
|
|
9
|
+
it 'assigns a uuid id' do
|
|
10
|
+
expect(heuristic.id).to match(/\A[0-9a-f-]{36}\z/)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
it 'stores pattern, domain, and response' do
|
|
14
|
+
expect(heuristic.pattern).to eq('greeting')
|
|
15
|
+
expect(heuristic.domain).to eq(:social)
|
|
16
|
+
expect(heuristic.response).to eq(:wave)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
it 'defaults confidence to DEFAULT_CONFIDENCE' do
|
|
20
|
+
expect(heuristic.confidence).to eq(Legion::Extensions::DualProcess::Helpers::Constants::DEFAULT_CONFIDENCE)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
it 'accepts custom confidence' do
|
|
24
|
+
h = described_class.new(pattern: :x, domain: :d, response: :r, confidence: 0.8)
|
|
25
|
+
expect(h.confidence).to eq(0.8)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
it 'clamps confidence to floor' do
|
|
29
|
+
h = described_class.new(pattern: :x, domain: :d, response: :r, confidence: -1.0)
|
|
30
|
+
expect(h.confidence).to eq(Legion::Extensions::DualProcess::Helpers::Constants::CONFIDENCE_FLOOR)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
it 'clamps confidence to ceiling' do
|
|
34
|
+
h = described_class.new(pattern: :x, domain: :d, response: :r, confidence: 2.0)
|
|
35
|
+
expect(h.confidence).to eq(Legion::Extensions::DualProcess::Helpers::Constants::CONFIDENCE_CEILING)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
it 'starts with zero use_count and success_count' do
|
|
39
|
+
expect(heuristic.use_count).to eq(0)
|
|
40
|
+
expect(heuristic.success_count).to eq(0)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
it 'sets created_at' do
|
|
44
|
+
expect(heuristic.created_at).to be_a(Time)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
it 'starts with nil last_used_at' do
|
|
48
|
+
expect(heuristic.last_used_at).to be_nil
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
describe '#use!' do
|
|
53
|
+
it 'increments use_count' do
|
|
54
|
+
heuristic.use!(success: true)
|
|
55
|
+
expect(heuristic.use_count).to eq(1)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
it 'increments success_count on success' do
|
|
59
|
+
heuristic.use!(success: true)
|
|
60
|
+
expect(heuristic.success_count).to eq(1)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
it 'does not increment success_count on failure' do
|
|
64
|
+
heuristic.use!(success: false)
|
|
65
|
+
expect(heuristic.success_count).to eq(0)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
it 'sets last_used_at' do
|
|
69
|
+
heuristic.use!(success: true)
|
|
70
|
+
expect(heuristic.last_used_at).to be_a(Time)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
it 'boosts confidence on success' do
|
|
74
|
+
original = heuristic.confidence
|
|
75
|
+
heuristic.use!(success: true)
|
|
76
|
+
expect(heuristic.confidence).to be > original
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
it 'reduces confidence on failure' do
|
|
80
|
+
original = heuristic.confidence
|
|
81
|
+
heuristic.use!(success: false)
|
|
82
|
+
expect(heuristic.confidence).to be < original
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
it 'does not exceed CONFIDENCE_CEILING' do
|
|
86
|
+
10.times { heuristic.use!(success: true) }
|
|
87
|
+
expect(heuristic.confidence).to be <= Legion::Extensions::DualProcess::Helpers::Constants::CONFIDENCE_CEILING
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
it 'does not go below CONFIDENCE_FLOOR' do
|
|
91
|
+
10.times { heuristic.use!(success: false) }
|
|
92
|
+
expect(heuristic.confidence).to be >= Legion::Extensions::DualProcess::Helpers::Constants::CONFIDENCE_FLOOR
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
describe '#success_rate' do
|
|
97
|
+
it 'returns 0.0 with no uses' do
|
|
98
|
+
expect(heuristic.success_rate).to eq(0.0)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
it 'computes ratio correctly' do
|
|
102
|
+
2.times { heuristic.use!(success: true) }
|
|
103
|
+
heuristic.use!(success: false)
|
|
104
|
+
expect(heuristic.success_rate).to be_within(0.001).of(2.0 / 3.0)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
describe '#reliable?' do
|
|
109
|
+
it 'returns false with no uses' do
|
|
110
|
+
expect(heuristic.reliable?).to be false
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
it 'returns false with fewer than 3 uses' do
|
|
114
|
+
2.times { heuristic.use!(success: true) }
|
|
115
|
+
expect(heuristic.reliable?).to be false
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
it 'returns true with high success rate and enough uses' do
|
|
119
|
+
5.times { heuristic.use!(success: true) }
|
|
120
|
+
expect(heuristic.reliable?).to be true
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
it 'returns false with low success rate even with many uses' do
|
|
124
|
+
5.times { heuristic.use!(success: false) }
|
|
125
|
+
expect(heuristic.reliable?).to be false
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
describe '#to_h' do
|
|
130
|
+
it 'includes all key fields' do
|
|
131
|
+
result = heuristic.to_h
|
|
132
|
+
expect(result).to include(:id, :pattern, :domain, :response, :confidence,
|
|
133
|
+
:use_count, :success_count, :success_rate, :reliable,
|
|
134
|
+
:created_at, :last_used_at)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
it 'reflects current state' do
|
|
138
|
+
heuristic.use!(success: true)
|
|
139
|
+
result = heuristic.to_h
|
|
140
|
+
expect(result[:use_count]).to eq(1)
|
|
141
|
+
expect(result[:success_count]).to eq(1)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
end
|