lex-tick 0.1.15 → 0.1.17

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e85775bbf80feaa0ab2ee2701639aaa40615c83b2bcd7614ad1bdedda75cb3e8
4
- data.tar.gz: 24d04ca1ac1b8306b5fcc08fa330c9b875ee60eb496cb4ae80de920f421ab85c
3
+ metadata.gz: 5adf45874a3807dccf25a41669745614b8c1c5051cea86634e15b9e68cec83d8
4
+ data.tar.gz: 313f7a0b71ee43ddb9e752c809419dd5b0dd6e8c7a7797e1abfe2cc7318b3e38
5
5
  SHA512:
6
- metadata.gz: 5f5b92c4ff69e1409e2b5dcd90d22c90ba940748dbe6436352f1c27a8fc5828661263b8f7d98888f260f828f19736e370f5ef0c3751ebad31f692415db543c56
7
- data.tar.gz: b9e3518b9bd20ccf17397b8733878772f1847711e690f9942952abc4f6bd02b94805a22605c1d19f96cff868e0c47556989d36e6b6fbac448c0bf1367780fa61
6
+ metadata.gz: 269f481503c6e5fdc94df7049b3eb19a8909d53434bfd34838b202b10aef4a17bea4083869d8651d000d1bf05bfb3d38d922e8a66fe244f5da109b37000abc66
7
+ data.tar.gz: 13b540ccc95b33b58508be8a133bb4c9823ff42dddfd643bb63babbfb892756acd589731bc6b696236ff72c7fdff0ac4273e5ad2dd60645c465878cf31c34b4b
@@ -55,7 +55,9 @@ module Legion
55
55
  SENTINEL_TIMEOUT = 3600 # seconds without any signal before demotion to dormant
56
56
  DREAM_IDLE_THRESHOLD = 1800 # seconds dormant with no signal before entering dream cycle
57
57
  SENTINEL_TO_DREAM_THRESHOLD = 600 # seconds sentinel with no signal before entering dream cycle
58
+ DREAM_BACKOFF_INTERVAL = 1800 # seconds after a completed dream before another dream cycle
58
59
  MAX_TICK_DURATION = 5.0 # hard ceiling for full active tick (seconds)
60
+ DREAM_TICK_BUDGET = 5.0 # hard ceiling for dormant-active dream tick (seconds)
59
61
  SENTINEL_TICK_BUDGET = 0.5 # time budget for sentinel tick
60
62
  DORMANT_TICK_BUDGET = 0.2 # time budget for dormant tick
61
63
  EMERGENCY_PROMOTION_BUDGET = 0.05 # max latency for emergency mode promotion
@@ -95,7 +97,7 @@ module Legion
95
97
  def tick_budget(mode)
96
98
  case mode
97
99
  when :dormant then DORMANT_TICK_BUDGET
98
- when :dormant_active then Float::INFINITY
100
+ when :dormant_active then DREAM_TICK_BUDGET
99
101
  when :sentinel then SENTINEL_TICK_BUDGET
100
102
  else MAX_TICK_DURATION
101
103
  end
@@ -5,7 +5,7 @@ module Legion
5
5
  module Tick
6
6
  module Helpers
7
7
  class State
8
- attr_reader :mode, :tick_count, :last_signal_at, :last_high_salience_at,
8
+ attr_reader :mode, :tick_count, :last_signal_at, :last_high_salience_at, :last_dream_completed_at,
9
9
  :phase_results, :current_phase, :mode_history
10
10
 
11
11
  def initialize(mode: :dormant)
@@ -13,6 +13,7 @@ module Legion
13
13
  @tick_count = 0
14
14
  @last_signal_at = nil
15
15
  @last_high_salience_at = nil
16
+ @last_dream_completed_at = nil
16
17
  @phase_results = {}
17
18
  @current_phase = nil
18
19
  @mode_history = [{ mode: mode, at: Time.now.utc }]
@@ -44,6 +45,10 @@ module Legion
44
45
  @mode_history.shift while @mode_history.size > 50
45
46
  end
46
47
 
48
+ def record_dream_completed
49
+ @last_dream_completed_at = Time.now.utc
50
+ end
51
+
47
52
  def seconds_since_signal
48
53
  return 0.0 unless @last_signal_at
49
54
 
@@ -56,14 +61,21 @@ module Legion
56
61
  Time.now.utc - @last_high_salience_at
57
62
  end
58
63
 
64
+ def seconds_since_dream_completed
65
+ return Float::INFINITY unless @last_dream_completed_at
66
+
67
+ Time.now.utc - @last_dream_completed_at
68
+ end
69
+
59
70
  def to_h
60
71
  {
61
- mode: @mode,
62
- tick_count: @tick_count,
63
- current_phase: @current_phase,
64
- last_signal_at: @last_signal_at,
65
- last_high_salience_at: @last_high_salience_at,
66
- phases_completed: @phase_results.keys
72
+ mode: @mode,
73
+ tick_count: @tick_count,
74
+ current_phase: @current_phase,
75
+ last_signal_at: @last_signal_at,
76
+ last_high_salience_at: @last_high_salience_at,
77
+ last_dream_completed_at: @last_dream_completed_at,
78
+ phases_completed: @phase_results.keys
67
79
  }
68
80
  end
69
81
  end
@@ -25,7 +25,7 @@ module Legion
25
25
  end
26
26
  end
27
27
 
28
- def evaluate_mode_transition(signals: [], emergency: nil, dream_complete: false, **) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
28
+ def evaluate_mode_transition(signals: [], emergency: nil, dream_complete: false, **) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity
29
29
  state = tick_state
30
30
  previous_mode = state.mode
31
31
 
@@ -44,7 +44,7 @@ module Legion
44
44
  when :dormant
45
45
  if signals.any?
46
46
  :sentinel
47
- elsif state.seconds_since_signal >= Helpers::Constants::DREAM_IDLE_THRESHOLD
47
+ elsif state.seconds_since_signal >= Helpers::Constants::DREAM_IDLE_THRESHOLD && dream_backoff_elapsed?(state)
48
48
  :dormant_active
49
49
  else
50
50
  :dormant
@@ -53,6 +53,7 @@ module Legion
53
53
  if max_salience >= Helpers::Constants::HIGH_SALIENCE_THRESHOLD || has_human
54
54
  :sentinel
55
55
  elsif dream_complete
56
+ state.record_dream_completed
56
57
  :dormant
57
58
  else
58
59
  :dormant_active
@@ -60,10 +61,11 @@ module Legion
60
61
  when :sentinel
61
62
  if has_human || max_salience >= Helpers::Constants::HIGH_SALIENCE_THRESHOLD
62
63
  :full_active
63
- elsif state.seconds_since_signal >= Helpers::Constants::SENTINEL_TO_DREAM_THRESHOLD
64
- :dormant_active
65
64
  elsif state.seconds_since_signal >= Helpers::Constants::SENTINEL_TIMEOUT
66
65
  :dormant
66
+ elsif state.seconds_since_signal >= Helpers::Constants::SENTINEL_TO_DREAM_THRESHOLD &&
67
+ dream_backoff_elapsed?(state)
68
+ :dormant_active
67
69
  else
68
70
  :sentinel
69
71
  end
@@ -98,9 +100,8 @@ module Legion
98
100
 
99
101
  previous = tick_state.mode
100
102
  tick_state.transition_to(mode)
101
- @mode_forced = true
102
- @force_expires_at = Time.now.utc + Helpers::Constants.tick_budget(mode).clamp(0, Helpers::Constants::MAX_TICK_DURATION)
103
- log.info "[tick] mode forced: #{previous} -> #{mode} (protected until #{@force_expires_at})"
103
+ @mode_forced = true
104
+ log.info "[tick] mode forced: #{previous} -> #{mode}"
104
105
  { mode: mode }
105
106
  end
106
107
 
@@ -120,8 +121,7 @@ module Legion
120
121
  # @mode_forced is set by set_mode and cleared here so the force lasts exactly one cycle.
121
122
  if @mode_forced
122
123
  log.debug "[tick] ##{state.tick_count} skipping mode evaluation (mode forced to #{state.mode})"
123
- @mode_forced = false
124
- @force_expires_at = nil
124
+ @mode_forced = false
125
125
  else
126
126
  evaluate_mode_transition(signals: signals)
127
127
  end
@@ -137,6 +137,7 @@ module Legion
137
137
  context: context
138
138
  }
139
139
  results = run_phases(phases, state, ctx)
140
+ finish_dream_cycle(state, phases, results)
140
141
 
141
142
  total_elapsed = Time.now.utc - start_time
142
143
  skipped = phases - results.keys
@@ -187,6 +188,28 @@ module Legion
187
188
  "elapsed=#{(total_elapsed * 1000).round(1)}ms#{skipped_suffix}"
188
189
  end
189
190
 
191
+ def finish_dream_cycle(state, phases, results)
192
+ return unless state.mode == :dormant_active
193
+ return unless phases == Helpers::Constants::DREAM_PHASES
194
+
195
+ skipped = phases - results.keys
196
+ failed = failed_dream_phases(results)
197
+ state.record_dream_completed
198
+ state.transition_to(:dormant)
199
+ if skipped.empty? && failed.empty?
200
+ log.info '[tick] dream cycle complete; backing off to dormant'
201
+ else
202
+ log.info "[tick] dream cycle deferred; backing off to dormant skipped=#{skipped} failed=#{failed}"
203
+ end
204
+ end
205
+
206
+ def failed_dream_phases(results)
207
+ results.each_with_object([]) do |(phase, result), failed|
208
+ status = result.is_a?(Hash) ? result[:status] : nil
209
+ failed << phase if %i[error failed failure timeout].include?(status)
210
+ end
211
+ end
212
+
190
213
  def full_active_cooldown_elapsed(state)
191
214
  if state.last_high_salience_at
192
215
  state.seconds_since_high_salience
@@ -197,6 +220,10 @@ module Legion
197
220
  end
198
221
  end
199
222
 
223
+ def dream_backoff_elapsed?(state)
224
+ state.seconds_since_dream_completed >= Helpers::Constants::DREAM_BACKOFF_INTERVAL
225
+ end
226
+
200
227
  def tick_state
201
228
  @tick_state ||= Helpers::State.new
202
229
  end
@@ -3,7 +3,7 @@
3
3
  module Legion
4
4
  module Extensions
5
5
  module Tick
6
- VERSION = '0.1.15'
6
+ VERSION = '0.1.17'
7
7
  end
8
8
  end
9
9
  end
@@ -87,8 +87,8 @@ RSpec.describe Legion::Extensions::Tick::Helpers::Constants do
87
87
  end
88
88
 
89
89
  describe '.tick_budget' do
90
- it 'returns Float::INFINITY for dormant_active' do
91
- expect(described_class.tick_budget(:dormant_active)).to eq(Float::INFINITY)
90
+ it 'returns DREAM_TICK_BUDGET for dormant_active' do
91
+ expect(described_class.tick_budget(:dormant_active)).to eq(described_class::DREAM_TICK_BUDGET)
92
92
  end
93
93
  end
94
94
 
@@ -100,5 +100,13 @@ RSpec.describe Legion::Extensions::Tick::Helpers::Constants do
100
100
  it 'defines SENTINEL_TO_DREAM_THRESHOLD as 600' do
101
101
  expect(described_class::SENTINEL_TO_DREAM_THRESHOLD).to eq(600)
102
102
  end
103
+
104
+ it 'defines DREAM_BACKOFF_INTERVAL as 1800' do
105
+ expect(described_class::DREAM_BACKOFF_INTERVAL).to eq(1800)
106
+ end
107
+
108
+ it 'defines DREAM_TICK_BUDGET as finite' do
109
+ expect(described_class::DREAM_TICK_BUDGET).to eq(5.0)
110
+ end
103
111
  end
104
112
  end
@@ -60,6 +60,19 @@ RSpec.describe Legion::Extensions::Tick::Helpers::State do
60
60
  end
61
61
  end
62
62
 
63
+ describe '#record_dream_completed' do
64
+ it 'updates last_dream_completed_at' do
65
+ state.record_dream_completed
66
+ expect(state.last_dream_completed_at).not_to be_nil
67
+ end
68
+ end
69
+
70
+ describe '#seconds_since_dream_completed' do
71
+ it 'treats missing dream completion history as eligible for a dream cycle' do
72
+ expect(state.seconds_since_dream_completed).to eq(Float::INFINITY)
73
+ end
74
+ end
75
+
63
76
  describe '#transition_to' do
64
77
  it 'changes mode' do
65
78
  state.transition_to(:sentinel)
@@ -83,6 +96,7 @@ RSpec.describe Legion::Extensions::Tick::Helpers::State do
83
96
  h = state.to_h
84
97
  expect(h).to have_key(:mode)
85
98
  expect(h).to have_key(:tick_count)
99
+ expect(h).to have_key(:last_dream_completed_at)
86
100
  expect(h).to have_key(:phases_completed)
87
101
  end
88
102
  end
@@ -67,6 +67,56 @@ RSpec.describe Legion::Extensions::Tick::Runners::Orchestrator do
67
67
  result = client.execute_tick(signals: [{ salience: 0.3 }])
68
68
  expect(result[:mode]).to eq(:sentinel)
69
69
  end
70
+
71
+ it 'completes one dormant_active dream cycle and backs off to dormant' do
72
+ client.set_mode(mode: :dormant_active)
73
+
74
+ result = client.execute_tick
75
+
76
+ expect(result[:phases_executed]).to eq(Legion::Extensions::Tick::Helpers::Constants::DREAM_PHASES)
77
+ expect(result[:mode]).to eq(:dormant)
78
+ expect(client.tick_status[:last_dream_completed_at]).not_to be_nil
79
+ end
80
+
81
+ it 'does not immediately restart a completed dream cycle on the next heartbeat' do
82
+ state = client.send(:tick_state)
83
+ allow(state).to receive(:seconds_since_signal).and_return(1801.0)
84
+
85
+ client.set_mode(mode: :dormant_active)
86
+ client.execute_tick
87
+ result = client.execute_tick
88
+
89
+ expect(result[:mode]).to eq(:dormant)
90
+ expect(result[:phases_executed]).to eq([:memory_consolidation])
91
+ end
92
+
93
+ it 'backs off to dormant when a dream phase fails' do
94
+ client.set_mode(mode: :dormant_active)
95
+
96
+ result = client.execute_tick(
97
+ phase_handlers: {
98
+ dream_narration: ->(**) { { status: :error } }
99
+ }
100
+ )
101
+
102
+ expect(result[:mode]).to eq(:dormant)
103
+ expect(result[:results][:dream_narration][:status]).to eq(:error)
104
+ expect(client.tick_status[:last_dream_completed_at]).not_to be_nil
105
+ end
106
+
107
+ it 'backs off to dormant when the dream tick budget is exhausted before all phases run' do
108
+ constants = Legion::Extensions::Tick::Helpers::Constants
109
+ allow(constants).to receive(:tick_budget).and_call_original
110
+ allow(constants).to receive(:tick_budget).with(:dormant_active).and_return(0.0)
111
+
112
+ client.set_mode(mode: :dormant_active)
113
+ result = client.execute_tick
114
+
115
+ expect(result[:mode]).to eq(:dormant)
116
+ expect(result[:phases_executed]).to be_empty
117
+ expect(result[:phases_skipped]).to eq(constants::DREAM_PHASES)
118
+ expect(client.tick_status[:last_dream_completed_at]).not_to be_nil
119
+ end
70
120
  end
71
121
 
72
122
  describe '#evaluate_mode_transition' do
@@ -138,6 +188,7 @@ RSpec.describe Legion::Extensions::Tick::Runners::Orchestrator do
138
188
  result = client.evaluate_mode_transition(dream_complete: true)
139
189
  expect(result[:transitioned]).to be true
140
190
  expect(result[:new_mode]).to eq(:dormant)
191
+ expect(client.tick_status[:last_dream_completed_at]).not_to be_nil
141
192
  end
142
193
 
143
194
  it 'stays dormant_active when no signals and dream not complete' do
@@ -146,6 +197,29 @@ RSpec.describe Legion::Extensions::Tick::Runners::Orchestrator do
146
197
  expect(result[:transitioned]).to be false
147
198
  expect(result[:current_mode]).to eq(:dormant_active)
148
199
  end
200
+
201
+ it 'keeps dormant backed off after a completed dream cycle' do
202
+ state = client.send(:tick_state)
203
+ state.record_dream_completed
204
+ allow(state).to receive(:seconds_since_signal).and_return(1801.0)
205
+
206
+ result = client.evaluate_mode_transition
207
+
208
+ expect(result[:transitioned]).to be false
209
+ expect(result[:current_mode]).to eq(:dormant)
210
+ end
211
+
212
+ it 'allows dormant_active again after dream backoff elapses' do
213
+ state = client.send(:tick_state)
214
+ state.record_dream_completed
215
+ allow(state).to receive(:seconds_since_signal).and_return(1801.0)
216
+ allow(state).to receive(:seconds_since_dream_completed).and_return(1801.0)
217
+
218
+ result = client.evaluate_mode_transition
219
+
220
+ expect(result[:transitioned]).to be true
221
+ expect(result[:new_mode]).to eq(:dormant_active)
222
+ end
149
223
  end
150
224
 
151
225
  context 'sentinel -> dormant_active' do
@@ -181,6 +255,12 @@ RSpec.describe Legion::Extensions::Tick::Runners::Orchestrator do
181
255
  expect(result[:mode]).to eq(:full_active)
182
256
  end
183
257
 
258
+ it 'does not expose an unused force expiration timestamp' do
259
+ client.set_mode(mode: :full_active)
260
+
261
+ expect(client.instance_variable_defined?(:@force_expires_at)).to be false
262
+ end
263
+
184
264
  it 'rejects invalid mode' do
185
265
  result = client.set_mode(mode: :invalid)
186
266
  expect(result[:error]).to eq(:invalid_mode)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lex-tick
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.15
4
+ version: 0.1.17
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity