lex-prospective-memory 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-prospective-memory.gemspec +29 -0
- data/lib/legion/extensions/prospective_memory/client.rb +24 -0
- data/lib/legion/extensions/prospective_memory/helpers/constants.rb +28 -0
- data/lib/legion/extensions/prospective_memory/helpers/intention.rb +90 -0
- data/lib/legion/extensions/prospective_memory/helpers/prospective_engine.rb +153 -0
- data/lib/legion/extensions/prospective_memory/runners/prospective_memory.rb +142 -0
- data/lib/legion/extensions/prospective_memory/version.rb +9 -0
- data/lib/legion/extensions/prospective_memory.rb +16 -0
- data/spec/legion/extensions/prospective_memory/client_spec.rb +24 -0
- data/spec/legion/extensions/prospective_memory/helpers/constants_spec.rb +84 -0
- data/spec/legion/extensions/prospective_memory/helpers/intention_spec.rb +193 -0
- data/spec/legion/extensions/prospective_memory/helpers/prospective_engine_spec.rb +255 -0
- data/spec/legion/extensions/prospective_memory/runners/prospective_memory_spec.rb +220 -0
- data/spec/spec_helper.rb +20 -0
- metadata +76 -0
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe Legion::Extensions::ProspectiveMemory::Helpers::Intention do
|
|
4
|
+
subject(:intention) do
|
|
5
|
+
described_class.new(
|
|
6
|
+
description: 'send follow-up email',
|
|
7
|
+
trigger_type: :time_based,
|
|
8
|
+
trigger_condition: { at: '2026-04-01T09:00:00Z' },
|
|
9
|
+
urgency: 0.6,
|
|
10
|
+
domain: 'communication'
|
|
11
|
+
)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
describe '#initialize' do
|
|
15
|
+
it 'assigns a UUID id' do
|
|
16
|
+
expect(intention.id).to match(/\A[0-9a-f-]{36}\z/)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
it 'sets status to :pending' do
|
|
20
|
+
expect(intention.status).to eq(:pending)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
it 'clamps urgency to 0.0-1.0' do
|
|
24
|
+
over = described_class.new(description: 'x', trigger_type: :time_based, trigger_condition: {}, urgency: 1.5)
|
|
25
|
+
expect(over.urgency).to eq(1.0)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
it 'clamps urgency below 0.0' do
|
|
29
|
+
under = described_class.new(description: 'x', trigger_type: :time_based, trigger_condition: {}, urgency: -0.3)
|
|
30
|
+
expect(under.urgency).to eq(0.0)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
it 'defaults urgency to DEFAULT_URGENCY' do
|
|
34
|
+
default_intention = described_class.new(description: 'x', trigger_type: :time_based, trigger_condition: {})
|
|
35
|
+
expect(default_intention.urgency).to eq(Legion::Extensions::ProspectiveMemory::Helpers::Constants::DEFAULT_URGENCY)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
it 'sets created_at to a UTC time' do
|
|
39
|
+
expect(intention.created_at).to be_a(Time)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
it 'leaves triggered_at nil initially' do
|
|
43
|
+
expect(intention.triggered_at).to be_nil
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
it 'leaves executed_at nil initially' do
|
|
47
|
+
expect(intention.executed_at).to be_nil
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
describe '#monitor!' do
|
|
52
|
+
it 'sets status to :monitoring' do
|
|
53
|
+
intention.monitor!
|
|
54
|
+
expect(intention.status).to eq(:monitoring)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
describe '#trigger!' do
|
|
59
|
+
it 'sets status to :triggered' do
|
|
60
|
+
intention.trigger!
|
|
61
|
+
expect(intention.status).to eq(:triggered)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
it 'sets triggered_at' do
|
|
65
|
+
intention.trigger!
|
|
66
|
+
expect(intention.triggered_at).to be_a(Time)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
describe '#execute!' do
|
|
71
|
+
it 'sets status to :executed' do
|
|
72
|
+
intention.execute!
|
|
73
|
+
expect(intention.status).to eq(:executed)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
it 'sets executed_at' do
|
|
77
|
+
intention.execute!
|
|
78
|
+
expect(intention.executed_at).to be_a(Time)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
describe '#expire!' do
|
|
83
|
+
it 'sets status to :expired' do
|
|
84
|
+
intention.expire!
|
|
85
|
+
expect(intention.status).to eq(:expired)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
describe '#cancel!' do
|
|
90
|
+
it 'sets status to :cancelled' do
|
|
91
|
+
intention.cancel!
|
|
92
|
+
expect(intention.status).to eq(:cancelled)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
describe '#expired?' do
|
|
97
|
+
it 'returns false when expires_at is nil' do
|
|
98
|
+
expect(intention.expired?).to be false
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
it 'returns false when expires_at is in the future' do
|
|
102
|
+
future = described_class.new(
|
|
103
|
+
description: 'x', trigger_type: :time_based, trigger_condition: {},
|
|
104
|
+
expires_at: Time.now.utc + 3600
|
|
105
|
+
)
|
|
106
|
+
expect(future.expired?).to be false
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
it 'returns true when expires_at is in the past' do
|
|
110
|
+
past = described_class.new(
|
|
111
|
+
description: 'x', trigger_type: :time_based, trigger_condition: {},
|
|
112
|
+
expires_at: Time.now.utc - 1
|
|
113
|
+
)
|
|
114
|
+
expect(past.expired?).to be true
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
describe '#boost_urgency!' do
|
|
119
|
+
it 'increases urgency by URGENCY_BOOST by default' do
|
|
120
|
+
original = intention.urgency
|
|
121
|
+
intention.boost_urgency!
|
|
122
|
+
expect(intention.urgency).to eq((original + Legion::Extensions::ProspectiveMemory::Helpers::Constants::URGENCY_BOOST).clamp(0.0, 1.0).round(10))
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
it 'accepts a custom amount' do
|
|
126
|
+
intention.boost_urgency!(amount: 0.2)
|
|
127
|
+
expect(intention.urgency).to be > 0.6
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
it 'does not exceed 1.0' do
|
|
131
|
+
high = described_class.new(description: 'x', trigger_type: :time_based, trigger_condition: {}, urgency: 0.95)
|
|
132
|
+
high.boost_urgency!(amount: 0.5)
|
|
133
|
+
expect(high.urgency).to eq(1.0)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
describe '#decay_urgency!' do
|
|
138
|
+
it 'decreases urgency by URGENCY_DECAY' do
|
|
139
|
+
original = intention.urgency
|
|
140
|
+
intention.decay_urgency!
|
|
141
|
+
expect(intention.urgency).to be < original
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
it 'does not go below 0.0' do
|
|
145
|
+
low = described_class.new(description: 'x', trigger_type: :time_based, trigger_condition: {}, urgency: 0.005)
|
|
146
|
+
low.decay_urgency!
|
|
147
|
+
expect(low.urgency).to eq(0.0)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
it 'rounds to 10 decimal places' do
|
|
151
|
+
intention.decay_urgency!
|
|
152
|
+
expect(intention.urgency.to_s.split('.').last.length).to be <= 10
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
describe '#urgency_label' do
|
|
157
|
+
it 'returns :critical for urgency >= 0.8' do
|
|
158
|
+
high = described_class.new(description: 'x', trigger_type: :time_based, trigger_condition: {}, urgency: 0.9)
|
|
159
|
+
expect(high.urgency_label).to eq(:critical)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
it 'returns :high for urgency in 0.6...0.8' do
|
|
163
|
+
expect(intention.urgency_label).to eq(:high)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
it 'returns :moderate for urgency in 0.4...0.6' do
|
|
167
|
+
mid = described_class.new(description: 'x', trigger_type: :time_based, trigger_condition: {}, urgency: 0.5)
|
|
168
|
+
expect(mid.urgency_label).to eq(:moderate)
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
it 'returns :low for urgency in 0.2...0.4' do
|
|
172
|
+
low = described_class.new(description: 'x', trigger_type: :time_based, trigger_condition: {}, urgency: 0.3)
|
|
173
|
+
expect(low.urgency_label).to eq(:low)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
it 'returns :deferred for urgency < 0.2' do
|
|
177
|
+
very_low = described_class.new(description: 'x', trigger_type: :time_based, trigger_condition: {}, urgency: 0.1)
|
|
178
|
+
expect(very_low.urgency_label).to eq(:deferred)
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
describe '#to_h' do
|
|
183
|
+
it 'returns a hash with all fields' do
|
|
184
|
+
h = intention.to_h
|
|
185
|
+
expect(h[:id]).to eq(intention.id)
|
|
186
|
+
expect(h[:description]).to eq('send follow-up email')
|
|
187
|
+
expect(h[:trigger_type]).to eq(:time_based)
|
|
188
|
+
expect(h[:status]).to eq(:pending)
|
|
189
|
+
expect(h[:domain]).to eq('communication')
|
|
190
|
+
expect(h[:urgency_label]).to eq(:high)
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
end
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe Legion::Extensions::ProspectiveMemory::Helpers::ProspectiveEngine do
|
|
4
|
+
subject(:engine) { described_class.new }
|
|
5
|
+
|
|
6
|
+
let(:basic_params) do
|
|
7
|
+
{
|
|
8
|
+
description: 'check in with team',
|
|
9
|
+
trigger_type: :event_based,
|
|
10
|
+
trigger_condition: { event: 'sprint_end' },
|
|
11
|
+
domain: 'work'
|
|
12
|
+
}
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def make_intention(**overrides)
|
|
16
|
+
engine.create_intention(**basic_params, **overrides)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
describe '#create_intention' do
|
|
20
|
+
it 'returns an Intention object' do
|
|
21
|
+
expect(make_intention).to be_a(Legion::Extensions::ProspectiveMemory::Helpers::Intention)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
it 'stores the intention' do
|
|
25
|
+
intention = make_intention
|
|
26
|
+
expect(engine.intentions[intention.id]).to eq(intention)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
it 'creates intention with :pending status' do
|
|
30
|
+
expect(make_intention.status).to eq(:pending)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
it 'respects custom urgency' do
|
|
34
|
+
intention = make_intention(urgency: 0.8)
|
|
35
|
+
expect(intention.urgency).to eq(0.8)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
it 'evicts oldest when at capacity' do
|
|
39
|
+
stub_const('Legion::Extensions::ProspectiveMemory::Helpers::Constants::MAX_INTENTIONS', 2)
|
|
40
|
+
first = make_intention
|
|
41
|
+
_second = make_intention
|
|
42
|
+
_third = make_intention
|
|
43
|
+
expect(engine.intentions.key?(first.id)).to be false
|
|
44
|
+
expect(engine.intentions.size).to eq(2)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
describe '#monitor_intention' do
|
|
49
|
+
it 'sets status to :monitoring' do
|
|
50
|
+
intention = make_intention
|
|
51
|
+
engine.monitor_intention(intention_id: intention.id)
|
|
52
|
+
expect(intention.status).to eq(:monitoring)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
it 'returns nil for unknown id' do
|
|
56
|
+
expect(engine.monitor_intention(intention_id: 'nope')).to be_nil
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
describe '#trigger_intention' do
|
|
61
|
+
it 'sets status to :triggered' do
|
|
62
|
+
intention = make_intention
|
|
63
|
+
engine.trigger_intention(intention_id: intention.id)
|
|
64
|
+
expect(intention.status).to eq(:triggered)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
it 'returns nil for unknown id' do
|
|
68
|
+
expect(engine.trigger_intention(intention_id: 'nope')).to be_nil
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
describe '#execute_intention' do
|
|
73
|
+
it 'sets status to :executed' do
|
|
74
|
+
intention = make_intention
|
|
75
|
+
engine.execute_intention(intention_id: intention.id)
|
|
76
|
+
expect(intention.status).to eq(:executed)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
describe '#cancel_intention' do
|
|
81
|
+
it 'sets status to :cancelled' do
|
|
82
|
+
intention = make_intention
|
|
83
|
+
engine.cancel_intention(intention_id: intention.id)
|
|
84
|
+
expect(intention.status).to eq(:cancelled)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
describe '#check_expirations' do
|
|
89
|
+
it 'expires overdue pending intentions' do
|
|
90
|
+
expired_intention = make_intention(expires_at: Time.now.utc - 1)
|
|
91
|
+
engine.check_expirations
|
|
92
|
+
expect(expired_intention.status).to eq(:expired)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
it 'expires overdue monitoring intentions' do
|
|
96
|
+
intention = make_intention(expires_at: Time.now.utc - 1)
|
|
97
|
+
engine.monitor_intention(intention_id: intention.id)
|
|
98
|
+
engine.check_expirations
|
|
99
|
+
expect(intention.status).to eq(:expired)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
it 'does not expire future intentions' do
|
|
103
|
+
future = make_intention(expires_at: Time.now.utc + 3600)
|
|
104
|
+
engine.check_expirations
|
|
105
|
+
expect(future.status).to eq(:pending)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
it 'does not expire already-executed intentions' do
|
|
109
|
+
intention = make_intention(expires_at: Time.now.utc - 1)
|
|
110
|
+
engine.execute_intention(intention_id: intention.id)
|
|
111
|
+
engine.check_expirations
|
|
112
|
+
expect(intention.status).to eq(:executed)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
it 'returns the count of expired intentions' do
|
|
116
|
+
make_intention(expires_at: Time.now.utc - 1)
|
|
117
|
+
make_intention(expires_at: Time.now.utc - 1)
|
|
118
|
+
make_intention(expires_at: Time.now.utc + 3600)
|
|
119
|
+
expect(engine.check_expirations).to eq(2)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
describe '#pending_intentions' do
|
|
124
|
+
it 'returns only pending intentions' do
|
|
125
|
+
i1 = make_intention
|
|
126
|
+
i2 = make_intention
|
|
127
|
+
engine.monitor_intention(intention_id: i2.id)
|
|
128
|
+
expect(engine.pending_intentions).to contain_exactly(i1)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
describe '#monitoring_intentions' do
|
|
133
|
+
it 'returns only monitoring intentions' do
|
|
134
|
+
intention = make_intention
|
|
135
|
+
engine.monitor_intention(intention_id: intention.id)
|
|
136
|
+
expect(engine.monitoring_intentions).to contain_exactly(intention)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
describe '#triggered_intentions' do
|
|
141
|
+
it 'returns only triggered intentions' do
|
|
142
|
+
intention = make_intention
|
|
143
|
+
engine.trigger_intention(intention_id: intention.id)
|
|
144
|
+
expect(engine.triggered_intentions).to contain_exactly(intention)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
describe '#by_domain' do
|
|
149
|
+
it 'filters by domain' do
|
|
150
|
+
work_intention = make_intention(domain: 'work')
|
|
151
|
+
make_intention(domain: 'personal')
|
|
152
|
+
results = engine.by_domain(domain: 'work')
|
|
153
|
+
expect(results).to contain_exactly(work_intention)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
describe '#by_urgency' do
|
|
158
|
+
it 'returns intentions at or above min_urgency' do
|
|
159
|
+
high = make_intention(urgency: 0.8)
|
|
160
|
+
_low = make_intention(urgency: 0.3)
|
|
161
|
+
result = engine.by_urgency(min_urgency: 0.5)
|
|
162
|
+
expect(result).to contain_exactly(high)
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
describe '#most_urgent' do
|
|
167
|
+
it 'returns intentions sorted by descending urgency' do
|
|
168
|
+
i1 = make_intention(urgency: 0.9)
|
|
169
|
+
i2 = make_intention(urgency: 0.4)
|
|
170
|
+
i3 = make_intention(urgency: 0.7)
|
|
171
|
+
result = engine.most_urgent(limit: 3)
|
|
172
|
+
expect(result).to eq([i1, i3, i2])
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
it 'excludes executed/expired/cancelled intentions' do
|
|
176
|
+
executed = make_intention(urgency: 0.9)
|
|
177
|
+
active = make_intention(urgency: 0.5)
|
|
178
|
+
engine.execute_intention(intention_id: executed.id)
|
|
179
|
+
result = engine.most_urgent(limit: 5)
|
|
180
|
+
expect(result).to contain_exactly(active)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
it 'respects the limit parameter' do
|
|
184
|
+
5.times { make_intention }
|
|
185
|
+
expect(engine.most_urgent(limit: 3).size).to eq(3)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
describe '#decay_all_urgency' do
|
|
190
|
+
it 'decreases urgency of pending intentions' do
|
|
191
|
+
intention = make_intention(urgency: 0.5)
|
|
192
|
+
engine.decay_all_urgency
|
|
193
|
+
expect(intention.urgency).to be < 0.5
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
it 'decreases urgency of monitoring intentions' do
|
|
197
|
+
intention = make_intention(urgency: 0.5)
|
|
198
|
+
engine.monitor_intention(intention_id: intention.id)
|
|
199
|
+
engine.decay_all_urgency
|
|
200
|
+
expect(intention.urgency).to be < 0.5
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
it 'does not decay executed intentions' do
|
|
204
|
+
intention = make_intention(urgency: 0.5)
|
|
205
|
+
engine.execute_intention(intention_id: intention.id)
|
|
206
|
+
engine.decay_all_urgency
|
|
207
|
+
expect(intention.urgency).to eq(0.5)
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
describe '#execution_rate' do
|
|
212
|
+
it 'returns 0.0 with no terminal intentions' do
|
|
213
|
+
make_intention
|
|
214
|
+
expect(engine.execution_rate).to eq(0.0)
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
it 'computes fraction of executed vs total terminal' do
|
|
218
|
+
i1 = make_intention
|
|
219
|
+
i2 = make_intention
|
|
220
|
+
i3 = make_intention
|
|
221
|
+
engine.execute_intention(intention_id: i1.id)
|
|
222
|
+
engine.execute_intention(intention_id: i2.id)
|
|
223
|
+
engine.cancel_intention(intention_id: i3.id)
|
|
224
|
+
expect(engine.execution_rate).to be_within(0.001).of(2.0 / 3.0)
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
describe '#intention_report' do
|
|
229
|
+
it 'returns a hash with total, by_status, execution_rate, most_urgent' do
|
|
230
|
+
make_intention
|
|
231
|
+
report = engine.intention_report
|
|
232
|
+
expect(report[:total]).to eq(1)
|
|
233
|
+
expect(report[:by_status]).to be_a(Hash)
|
|
234
|
+
expect(report[:execution_rate]).to be_a(Float)
|
|
235
|
+
expect(report[:most_urgent]).to be_an(Array)
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
it 'includes all status types in by_status' do
|
|
239
|
+
report = engine.intention_report
|
|
240
|
+
Legion::Extensions::ProspectiveMemory::Helpers::Constants::STATUS_TYPES.each do |status|
|
|
241
|
+
expect(report[:by_status]).to have_key(status)
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
describe '#to_h' do
|
|
247
|
+
it 'serializes the engine state' do
|
|
248
|
+
make_intention
|
|
249
|
+
h = engine.to_h
|
|
250
|
+
expect(h[:intention_count]).to eq(1)
|
|
251
|
+
expect(h[:intentions]).to be_a(Hash)
|
|
252
|
+
expect(h[:execution_rate]).to be_a(Float)
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/prospective_memory/client'
|
|
4
|
+
|
|
5
|
+
RSpec.describe Legion::Extensions::ProspectiveMemory::Runners::ProspectiveMemory do
|
|
6
|
+
let(:client) { Legion::Extensions::ProspectiveMemory::Client.new }
|
|
7
|
+
|
|
8
|
+
let(:base_params) do
|
|
9
|
+
{
|
|
10
|
+
description: 'review open pull requests',
|
|
11
|
+
trigger_type: :activity_based,
|
|
12
|
+
trigger_condition: { activity: 'commit_pushed' }
|
|
13
|
+
}
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def create(**overrides)
|
|
17
|
+
client.create_intention(**base_params, **overrides)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
describe '#create_intention' do
|
|
21
|
+
it 'creates an intention and returns a hash with :created true' do
|
|
22
|
+
result = create
|
|
23
|
+
expect(result[:created]).to be true
|
|
24
|
+
expect(result[:intention]).to be_a(Hash)
|
|
25
|
+
expect(result[:intention][:id]).to match(/\A[0-9a-f-]{36}\z/)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
it 'rejects invalid trigger_type' do
|
|
29
|
+
result = create(trigger_type: :bogus)
|
|
30
|
+
expect(result[:error]).to eq(:invalid_trigger_type)
|
|
31
|
+
expect(result[:valid_types]).to include(:time_based)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
it 'stores urgency on the intention' do
|
|
35
|
+
result = create(urgency: 0.75)
|
|
36
|
+
expect(result[:intention][:urgency]).to eq(0.75)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
it 'stores domain on the intention' do
|
|
40
|
+
result = create(domain: 'engineering')
|
|
41
|
+
expect(result[:intention][:domain]).to eq('engineering')
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
it 'sets status to :pending' do
|
|
45
|
+
result = create
|
|
46
|
+
expect(result[:intention][:status]).to eq(:pending)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
describe '#monitor_intention' do
|
|
51
|
+
it 'transitions status to :monitoring' do
|
|
52
|
+
created = create
|
|
53
|
+
result = client.monitor_intention(intention_id: created[:intention][:id])
|
|
54
|
+
expect(result[:updated]).to be true
|
|
55
|
+
expect(result[:intention][:status]).to eq(:monitoring)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
it 'returns not_found for unknown id' do
|
|
59
|
+
result = client.monitor_intention(intention_id: 'nonexistent')
|
|
60
|
+
expect(result[:updated]).to be false
|
|
61
|
+
expect(result[:reason]).to eq(:not_found)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
describe '#trigger_intention' do
|
|
66
|
+
it 'transitions status to :triggered' do
|
|
67
|
+
created = create
|
|
68
|
+
result = client.trigger_intention(intention_id: created[:intention][:id])
|
|
69
|
+
expect(result[:updated]).to be true
|
|
70
|
+
expect(result[:intention][:status]).to eq(:triggered)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
it 'records triggered_at timestamp' do
|
|
74
|
+
created = create
|
|
75
|
+
result = client.trigger_intention(intention_id: created[:intention][:id])
|
|
76
|
+
expect(result[:intention][:triggered_at]).not_to be_nil
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
describe '#execute_intention' do
|
|
81
|
+
it 'transitions status to :executed' do
|
|
82
|
+
created = create
|
|
83
|
+
result = client.execute_intention(intention_id: created[:intention][:id])
|
|
84
|
+
expect(result[:updated]).to be true
|
|
85
|
+
expect(result[:intention][:status]).to eq(:executed)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
it 'records executed_at timestamp' do
|
|
89
|
+
created = create
|
|
90
|
+
result = client.execute_intention(intention_id: created[:intention][:id])
|
|
91
|
+
expect(result[:intention][:executed_at]).not_to be_nil
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
it 'returns not_found for unknown id' do
|
|
95
|
+
result = client.execute_intention(intention_id: 'nope')
|
|
96
|
+
expect(result[:updated]).to be false
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
describe '#cancel_intention' do
|
|
101
|
+
it 'transitions status to :cancelled' do
|
|
102
|
+
created = create
|
|
103
|
+
result = client.cancel_intention(intention_id: created[:intention][:id])
|
|
104
|
+
expect(result[:updated]).to be true
|
|
105
|
+
expect(result[:intention][:status]).to eq(:cancelled)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
describe '#check_expirations' do
|
|
110
|
+
it 'returns expired_count' do
|
|
111
|
+
client.create_intention(
|
|
112
|
+
description: 'expired',
|
|
113
|
+
trigger_type: :time_based,
|
|
114
|
+
trigger_condition: {},
|
|
115
|
+
expires_at: Time.now.utc - 1
|
|
116
|
+
)
|
|
117
|
+
result = client.check_expirations
|
|
118
|
+
expect(result[:expired_count]).to eq(1)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
describe '#pending_intentions' do
|
|
123
|
+
it 'lists pending intentions with count' do
|
|
124
|
+
create
|
|
125
|
+
create
|
|
126
|
+
result = client.pending_intentions
|
|
127
|
+
expect(result[:count]).to eq(2)
|
|
128
|
+
expect(result[:intentions]).to all(include(status: :pending))
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
describe '#monitoring_intentions' do
|
|
133
|
+
it 'lists monitoring intentions' do
|
|
134
|
+
created = create
|
|
135
|
+
client.monitor_intention(intention_id: created[:intention][:id])
|
|
136
|
+
result = client.monitoring_intentions
|
|
137
|
+
expect(result[:count]).to eq(1)
|
|
138
|
+
expect(result[:intentions].first[:status]).to eq(:monitoring)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
describe '#triggered_intentions' do
|
|
143
|
+
it 'lists triggered intentions' do
|
|
144
|
+
created = create
|
|
145
|
+
client.trigger_intention(intention_id: created[:intention][:id])
|
|
146
|
+
result = client.triggered_intentions
|
|
147
|
+
expect(result[:count]).to eq(1)
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
describe '#intentions_by_domain' do
|
|
152
|
+
it 'filters by domain' do
|
|
153
|
+
create(domain: 'ops')
|
|
154
|
+
create(domain: 'dev')
|
|
155
|
+
result = client.intentions_by_domain(domain: 'ops')
|
|
156
|
+
expect(result[:count]).to eq(1)
|
|
157
|
+
expect(result[:domain]).to eq('ops')
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
describe '#intentions_by_urgency' do
|
|
162
|
+
it 'filters by min_urgency' do
|
|
163
|
+
create(urgency: 0.9)
|
|
164
|
+
create(urgency: 0.2)
|
|
165
|
+
result = client.intentions_by_urgency(min_urgency: 0.5)
|
|
166
|
+
expect(result[:count]).to eq(1)
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
describe '#most_urgent_intentions' do
|
|
171
|
+
it 'returns sorted by descending urgency' do
|
|
172
|
+
create(urgency: 0.3)
|
|
173
|
+
create(urgency: 0.8)
|
|
174
|
+
result = client.most_urgent_intentions(limit: 5)
|
|
175
|
+
urgencies = result[:intentions].map { |i| i[:urgency] }
|
|
176
|
+
expect(urgencies).to eq(urgencies.sort.reverse)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
it 'respects the limit' do
|
|
180
|
+
3.times { create }
|
|
181
|
+
result = client.most_urgent_intentions(limit: 2)
|
|
182
|
+
expect(result[:count]).to eq(2)
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
describe '#decay_urgency' do
|
|
187
|
+
it 'returns decayed: true' do
|
|
188
|
+
create
|
|
189
|
+
result = client.decay_urgency
|
|
190
|
+
expect(result[:decayed]).to be true
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
describe '#execution_rate' do
|
|
195
|
+
it 'returns a float execution_rate' do
|
|
196
|
+
result = client.execution_rate
|
|
197
|
+
expect(result[:execution_rate]).to be_a(Float)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
it 'computes rate after executions' do
|
|
201
|
+
c1 = create
|
|
202
|
+
c2 = create
|
|
203
|
+
client.execute_intention(intention_id: c1[:intention][:id])
|
|
204
|
+
client.cancel_intention(intention_id: c2[:intention][:id])
|
|
205
|
+
result = client.execution_rate
|
|
206
|
+
expect(result[:execution_rate]).to be_within(0.001).of(0.5)
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
describe '#intention_report' do
|
|
211
|
+
it 'returns total, by_status, execution_rate, most_urgent' do
|
|
212
|
+
create
|
|
213
|
+
report = client.intention_report
|
|
214
|
+
expect(report[:total]).to be >= 1
|
|
215
|
+
expect(report[:by_status]).to be_a(Hash)
|
|
216
|
+
expect(report[:execution_rate]).to be_a(Float)
|
|
217
|
+
expect(report[:most_urgent]).to be_an(Array)
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
data/spec/spec_helper.rb
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'bundler/setup'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module Logging
|
|
7
|
+
def self.debug(_msg); end
|
|
8
|
+
def self.info(_msg); end
|
|
9
|
+
def self.warn(_msg); end
|
|
10
|
+
def self.error(_msg); end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
require 'legion/extensions/prospective_memory'
|
|
15
|
+
|
|
16
|
+
RSpec.configure do |config|
|
|
17
|
+
config.example_status_persistence_file_path = '.rspec_status'
|
|
18
|
+
config.disable_monkey_patching!
|
|
19
|
+
config.expect_with(:rspec) { |c| c.syntax = :expect }
|
|
20
|
+
end
|