lex-tick 0.1.14 → 0.1.16

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: 5057fa93fb198fd044c7bf73802d9e454311d0913bf32c8c01b73cb508f0ed02
4
- data.tar.gz: 85ee186078e9db6e4d7250de0745f51d5c85703385c2e9396c21ed947f1b57fd
3
+ metadata.gz: 94918234bd1b19854887b794867550d1a6252f74ea4ad10dd6009b6df2797c5b
4
+ data.tar.gz: df175d240f2a5816e9b7be4269aa12880051e1bbe5bdf6f761731cca585b0e10
5
5
  SHA512:
6
- metadata.gz: 43833d847d50dd1e1a5a88549c1082c5a076198ee04cf8cf511b6dca3029b1b8c357d435bf6838fa5b8f3a06cf45dab6477f041219326c2f609b7c9ef4ab8179
7
- data.tar.gz: e74030f8aabd9eb496a01b18b36e782cd61cfb6be83cb3ba842c1d5ca62098a2defb9c2c02a4a3c28a465805bce74dee5c25c487fad75371878ebcae5ba0659d
6
+ metadata.gz: 291b85fa4cfe572c00bb279026569c08691daf1233d7900ffe619e6c4f8d15b3cc9241d18f98aa8ef22715a0438a78a1239919181decf4463f8ab26e13c708cc
7
+ data.tar.gz: 56b3a74946173477cbd2d3d85a6b35ff15cc61472b9065bade72af63dcbdab5a8392ceaf50a54ca7e4397db01142cf6d4ee702786d60d938bbd7fa2ff541f954
data/lex-tick.gemspec CHANGED
@@ -9,7 +9,7 @@ Gem::Specification.new do |spec|
9
9
  spec.email = ['matthewdiverson@gmail.com']
10
10
 
11
11
  spec.summary = 'LEX Tick'
12
- spec.description = 'Atomic cognitive processing cycle (11 phases, 3 modes) for brain-modeled agentic AI'
12
+ spec.description = 'Atomic cognitive processing cycle (16 phases, 4 modes, 10 dream phases) for brain-modeled agentic AI'
13
13
  spec.homepage = 'https://github.com/LegionIO/lex-tick'
14
14
  spec.license = 'MIT'
15
15
  spec.required_ruby_version = '>= 3.4'
@@ -55,12 +55,16 @@ 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
62
64
 
63
- # Phase timing budgets (fraction of total tick time)
65
+ # Phase timing budgets (fraction of total tick time).
66
+ # Informational only — not enforced by run_phases, which uses the tick-level budget.
67
+ # Useful as a reference for callers that want to self-limit within a phase handler.
64
68
  PHASE_BUDGETS = {
65
69
  sensory_processing: 0.12,
66
70
  emotional_evaluation: 0.08,
@@ -93,7 +97,7 @@ module Legion
93
97
  def tick_budget(mode)
94
98
  case mode
95
99
  when :dormant then DORMANT_TICK_BUDGET
96
- when :dormant_active then Float::INFINITY
100
+ when :dormant_active then DREAM_TICK_BUDGET
97
101
  when :sentinel then SENTINEL_TICK_BUDGET
98
102
  else MAX_TICK_DURATION
99
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,7 +100,9 @@ module Legion
98
100
 
99
101
  previous = tick_state.mode
100
102
  tick_state.transition_to(mode)
101
- log.info "[tick] mode forced: #{previous} -> #{mode}"
103
+ @mode_forced = true
104
+ @force_expires_at = Time.now.utc + Helpers::Constants.tick_budget(mode).clamp(0, Helpers::Constants::MAX_TICK_DURATION)
105
+ log.info "[tick] mode forced: #{previous} -> #{mode} (protected until #{@force_expires_at})"
102
106
  { mode: mode }
103
107
  end
104
108
 
@@ -114,7 +118,15 @@ module Legion
114
118
 
115
119
  log.debug "[tick] ##{state.tick_count} starting | mode=#{state.mode} signals=#{signals.size} max_salience=#{max_salience.round(2)}"
116
120
 
117
- evaluate_mode_transition(signals: signals)
121
+ # Skip automatic mode evaluation for the one tick following a forced set_mode call.
122
+ # @mode_forced is set by set_mode and cleared here so the force lasts exactly one cycle.
123
+ if @mode_forced
124
+ log.debug "[tick] ##{state.tick_count} skipping mode evaluation (mode forced to #{state.mode})"
125
+ @mode_forced = false
126
+ @force_expires_at = nil
127
+ else
128
+ evaluate_mode_transition(signals: signals)
129
+ end
118
130
 
119
131
  phases = Helpers::Constants.phases_for_mode(state.mode)
120
132
  budget = Helpers::Constants.tick_budget(state.mode)
@@ -127,6 +139,7 @@ module Legion
127
139
  context: context
128
140
  }
129
141
  results = run_phases(phases, state, ctx)
142
+ finish_dream_cycle(state, phases, results)
130
143
 
131
144
  total_elapsed = Time.now.utc - start_time
132
145
  skipped = phases - results.keys
@@ -177,6 +190,28 @@ module Legion
177
190
  "elapsed=#{(total_elapsed * 1000).round(1)}ms#{skipped_suffix}"
178
191
  end
179
192
 
193
+ def finish_dream_cycle(state, phases, results)
194
+ return unless state.mode == :dormant_active
195
+ return unless phases == Helpers::Constants::DREAM_PHASES
196
+
197
+ skipped = phases - results.keys
198
+ failed = failed_dream_phases(results)
199
+ state.record_dream_completed
200
+ state.transition_to(:dormant)
201
+ if skipped.empty? && failed.empty?
202
+ log.info '[tick] dream cycle complete; backing off to dormant'
203
+ else
204
+ log.info "[tick] dream cycle deferred; backing off to dormant skipped=#{skipped} failed=#{failed}"
205
+ end
206
+ end
207
+
208
+ def failed_dream_phases(results)
209
+ results.each_with_object([]) do |(phase, result), failed|
210
+ status = result.is_a?(Hash) ? result[:status] : nil
211
+ failed << phase if %i[error failed failure timeout].include?(status)
212
+ end
213
+ end
214
+
180
215
  def full_active_cooldown_elapsed(state)
181
216
  if state.last_high_salience_at
182
217
  state.seconds_since_high_salience
@@ -187,6 +222,10 @@ module Legion
187
222
  end
188
223
  end
189
224
 
225
+ def dream_backoff_elapsed?(state)
226
+ state.seconds_since_dream_completed >= Helpers::Constants::DREAM_BACKOFF_INTERVAL
227
+ end
228
+
190
229
  def tick_state
191
230
  @tick_state ||= Helpers::State.new
192
231
  end
@@ -3,7 +3,7 @@
3
3
  module Legion
4
4
  module Extensions
5
5
  module Tick
6
- VERSION = '0.1.14'
6
+ VERSION = '0.1.16'
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
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.14
4
+ version: 0.1.16
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -107,8 +107,8 @@ dependencies:
107
107
  - - ">="
108
108
  - !ruby/object:Gem::Version
109
109
  version: 1.3.9
110
- description: Atomic cognitive processing cycle (11 phases, 3 modes) for brain-modeled
111
- agentic AI
110
+ description: Atomic cognitive processing cycle (16 phases, 4 modes, 10 dream phases)
111
+ for brain-modeled agentic AI
112
112
  email:
113
113
  - matthewdiverson@gmail.com
114
114
  executables: []