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.
@@ -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