lex-tick 0.1.17 → 0.1.18

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: 5adf45874a3807dccf25a41669745614b8c1c5051cea86634e15b9e68cec83d8
4
- data.tar.gz: 313f7a0b71ee43ddb9e752c809419dd5b0dd6e8c7a7797e1abfe2cc7318b3e38
3
+ metadata.gz: 86c832a54da11de90ede212d84cfd65a4fed13510e0d4cf7307e3127723fdaf9
4
+ data.tar.gz: 232d3c7e96e3f9c78d86e3452e2f50429a5c09eee1336643d87b39df5c06a5f2
5
5
  SHA512:
6
- metadata.gz: 269f481503c6e5fdc94df7049b3eb19a8909d53434bfd34838b202b10aef4a17bea4083869d8651d000d1bf05bfb3d38d922e8a66fe244f5da109b37000abc66
7
- data.tar.gz: 13b540ccc95b33b58508be8a133bb4c9823ff42dddfd643bb63babbfb892756acd589731bc6b696236ff72c7fdff0ac4273e5ad2dd60645c465878cf31c34b4b
6
+ metadata.gz: 8d8319d73e2fed107c41db9066436b169a469aa3315a1b1bbe08dab283544676711f310e82e537b6c6980cc95f3906043b1d85d308abac57ae10d9b3c4a45585
7
+ data.tar.gz: 89678c7b0ed29423b3267387f39666031237e0a5eedcd47e7fa033532c4a57db85640f94f839ea453f0f3a03520acec07393b495c80c8d5ff23be217d674f708
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'legion/logging'
3
4
  require 'legion/extensions/tick/helpers/constants'
4
5
  require 'legion/extensions/tick/helpers/state'
5
6
  require 'legion/extensions/tick/runners/orchestrator'
@@ -8,6 +9,7 @@ module Legion
8
9
  module Extensions
9
10
  module Tick
10
11
  class Client
12
+ include Legion::Logging::Helper
11
13
  include Runners::Orchestrator
12
14
 
13
15
  def initialize(mode: :dormant, **)
@@ -50,6 +50,13 @@ module Legion
50
50
  full_active: PHASES
51
51
  }.freeze
52
52
 
53
+ # Only bounded, consumer-facing work belongs in the optional tick schedule.
54
+ OPTIONAL_PHASES = {
55
+ global_workspace: { mode: :full_active, after: :gut_instinct, requires: %i[sensory_processing].freeze }.freeze,
56
+ metacognition: { mode: :full_active, after: :post_tick_reflection, requires: %i[post_tick_reflection].freeze }.freeze
57
+ }.freeze
58
+ INCOMPLETE_PHASE_STATUSES = %i[error failed failure timeout no_handler skipped deferred unavailable no_result].freeze
59
+
53
60
  # Timing constants (in seconds)
54
61
  ACTIVE_TIMEOUT = 300 # seconds without high-salience signal before demotion
55
62
  SENTINEL_TIMEOUT = 3600 # seconds without any signal before demotion to dormant
@@ -90,8 +97,26 @@ module Legion
90
97
 
91
98
  module_function
92
99
 
93
- def phases_for_mode(mode)
94
- MODE_PHASES.fetch(mode, PHASES)
100
+ def phases_for_mode(mode, enabled_optional_phases: [])
101
+ validate_optional_phases(enabled_optional_phases)
102
+ phases = MODE_PHASES.fetch(mode, PHASES)
103
+ return phases if enabled_optional_phases.empty?
104
+
105
+ phases.each_with_object([]) do |phase, scheduled|
106
+ scheduled << phase
107
+ OPTIONAL_PHASES.each do |optional, contract|
108
+ next unless enabled_optional_phases.include?(optional) && contract[:mode] == mode && contract[:after] == phase
109
+
110
+ scheduled << optional
111
+ end
112
+ end
113
+ end
114
+
115
+ def validate_optional_phases(phases)
116
+ raise ArgumentError, 'enabled_optional_phases must be an Array of phase symbols' unless phases.is_a?(Array)
117
+
118
+ unsupported = phases.reject { |phase| OPTIONAL_PHASES.key?(phase) }
119
+ raise ArgumentError, "unsupported optional phases: #{unsupported.inspect}" unless unsupported.empty?
95
120
  end
96
121
 
97
122
  def tick_budget(mode)
@@ -6,7 +6,7 @@ module Legion
6
6
  module Helpers
7
7
  class State
8
8
  attr_reader :mode, :tick_count, :last_signal_at, :last_high_salience_at, :last_dream_completed_at,
9
- :phase_results, :current_phase, :mode_history
9
+ :last_dream_deferred_at, :phase_results, :current_phase, :mode_history
10
10
 
11
11
  def initialize(mode: :dormant)
12
12
  @mode = mode
@@ -14,6 +14,7 @@ module Legion
14
14
  @last_signal_at = nil
15
15
  @last_high_salience_at = nil
16
16
  @last_dream_completed_at = nil
17
+ @last_dream_deferred_at = nil
17
18
  @phase_results = {}
18
19
  @current_phase = nil
19
20
  @mode_history = [{ mode: mode, at: Time.now.utc }]
@@ -49,6 +50,10 @@ module Legion
49
50
  @last_dream_completed_at = Time.now.utc
50
51
  end
51
52
 
53
+ def record_dream_deferred
54
+ @last_dream_deferred_at = Time.now.utc
55
+ end
56
+
52
57
  def seconds_since_signal
53
58
  return 0.0 unless @last_signal_at
54
59
 
@@ -67,6 +72,12 @@ module Legion
67
72
  Time.now.utc - @last_dream_completed_at
68
73
  end
69
74
 
75
+ def seconds_since_dream_deferred
76
+ return Float::INFINITY unless @last_dream_deferred_at
77
+
78
+ Time.now.utc - @last_dream_deferred_at
79
+ end
80
+
70
81
  def to_h
71
82
  {
72
83
  mode: @mode,
@@ -75,6 +86,7 @@ module Legion
75
86
  last_signal_at: @last_signal_at,
76
87
  last_high_salience_at: @last_high_salience_at,
77
88
  last_dream_completed_at: @last_dream_completed_at,
89
+ last_dream_deferred_at: @last_dream_deferred_at,
78
90
  phases_completed: @phase_results.keys
79
91
  }
80
92
  end
@@ -12,16 +12,19 @@ module Legion
12
12
  false
13
13
  end
14
14
 
15
- def execute_tick(signals: [], phase_handlers: {}, **context)
15
+ def execute_tick(signals: [], phase_handlers: {}, enabled_optional_phases: [], **context)
16
+ Helpers::Constants.validate_optional_phases(enabled_optional_phases)
16
17
  if defined?(Legion::Telemetry::OpenInference)
17
18
  state = tick_state
18
19
  Legion::Telemetry::OpenInference.agent_span(
19
20
  name: "tick-#{state.tick_count + 1}", mode: state.mode,
20
- phase_count: Helpers::Constants.phases_for_mode(state.mode).size,
21
+ phase_count: Helpers::Constants.phases_for_mode(state.mode, enabled_optional_phases: enabled_optional_phases).size,
21
22
  budget_ms: (Helpers::Constants.tick_budget(state.mode) * 1000).round
22
- ) { |_span| execute_tick_impl(signals: signals, phase_handlers: phase_handlers, **context) }
23
+ ) do |_span|
24
+ execute_tick_impl(signals: signals, phase_handlers: phase_handlers, enabled_optional_phases: enabled_optional_phases, **context)
25
+ end
23
26
  else
24
- execute_tick_impl(signals: signals, phase_handlers: phase_handlers, **context)
27
+ execute_tick_impl(signals: signals, phase_handlers: phase_handlers, enabled_optional_phases: enabled_optional_phases, **context)
25
28
  end
26
29
  end
27
30
 
@@ -42,7 +45,9 @@ module Legion
42
45
 
43
46
  new_mode = case state.mode
44
47
  when :dormant
45
- if signals.any?
48
+ if has_human || max_salience >= Helpers::Constants::HIGH_SALIENCE_THRESHOLD
49
+ :full_active
50
+ elsif signals.any?
46
51
  :sentinel
47
52
  elsif state.seconds_since_signal >= Helpers::Constants::DREAM_IDLE_THRESHOLD && dream_backoff_elapsed?(state)
48
53
  :dormant_active
@@ -51,7 +56,7 @@ module Legion
51
56
  end
52
57
  when :dormant_active
53
58
  if max_salience >= Helpers::Constants::HIGH_SALIENCE_THRESHOLD || has_human
54
- :sentinel
59
+ :full_active
55
60
  elsif dream_complete
56
61
  state.record_dream_completed
57
62
  :dormant
@@ -107,7 +112,7 @@ module Legion
107
112
 
108
113
  private
109
114
 
110
- def execute_tick_impl(signals:, phase_handlers:, **context)
115
+ def execute_tick_impl(signals:, phase_handlers:, enabled_optional_phases:, **context)
111
116
  state = tick_state
112
117
  state.increment_tick
113
118
 
@@ -126,9 +131,9 @@ module Legion
126
131
  evaluate_mode_transition(signals: signals)
127
132
  end
128
133
 
129
- phases = Helpers::Constants.phases_for_mode(state.mode)
134
+ phases = Helpers::Constants.phases_for_mode(state.mode, enabled_optional_phases: enabled_optional_phases)
130
135
  budget = Helpers::Constants.tick_budget(state.mode)
131
- start_time = Time.now.utc
136
+ start_time = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
132
137
  ctx = {
133
138
  budget: budget,
134
139
  start_time: start_time,
@@ -139,18 +144,26 @@ module Legion
139
144
  results = run_phases(phases, state, ctx)
140
145
  finish_dream_cycle(state, phases, results)
141
146
 
142
- total_elapsed = Time.now.utc - start_time
147
+ total_elapsed = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - start_time
143
148
  skipped = phases - results.keys
144
149
  log_tick_complete(state, results, phases, total_elapsed, skipped)
145
150
 
146
151
  {
147
- tick_number: state.tick_count,
148
- mode: state.mode,
149
- phases_executed: results.keys,
150
- phases_skipped: skipped,
151
- results: results,
152
- elapsed: total_elapsed
152
+ tick_number: state.tick_count,
153
+ mode: state.mode,
154
+ phases_scheduled: phases,
155
+ phases_executed: results.keys,
156
+ phases_skipped: skipped,
157
+ results: results,
158
+ elapsed: total_elapsed
153
159
  }
160
+ rescue StandardError => e
161
+ log.error "[tick] execution failed: #{e.class}: #{e.message}"
162
+ if state.mode == :dormant_active
163
+ state.record_dream_deferred
164
+ state.transition_to(:dormant)
165
+ end
166
+ raise
154
167
  end
155
168
 
156
169
  def run_phases(phases, state, ctx)
@@ -159,7 +172,7 @@ module Legion
159
172
  start_time = ctx[:start_time]
160
173
  log.debug "[tick] ##{state.tick_count} running #{phases.size} phases with #{budget}s budget"
161
174
  phases.each do |phase|
162
- elapsed = Time.now.utc - start_time
175
+ elapsed = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - start_time
163
176
  if elapsed >= budget
164
177
  log.debug "[tick] ##{state.tick_count} budget exhausted at #{elapsed.round(3)}s, skipping remaining phases"
165
178
  break
@@ -173,14 +186,35 @@ module Legion
173
186
  end
174
187
 
175
188
  def run_single_phase(phase, handler, state, signals, results, **context)
176
- phase_start = Time.now.utc
177
- result = handler ? handler.call(state: state, signals: signals, prior_results: results, **context) : { status: :no_handler }
178
- phase_elapsed = ((Time.now.utc - phase_start) * 1000).round(1)
189
+ unavailable = optional_phase_unavailable(phase, handler, results)
190
+
191
+ phase_start = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
192
+ result = unavailable || if handler
193
+ handler.call(state: state, signals: signals, prior_results: results, **context)
194
+ else
195
+ { status: :no_handler }
196
+ end
197
+ phase_elapsed = ((::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - phase_start) * 1000).round(1)
179
198
  status = result.is_a?(Hash) ? (result[:status] || :ok) : :ok
180
199
  log.debug "[tick] ##{state.tick_count} phase=#{phase} status=#{status} (#{phase_elapsed}ms)"
181
200
  result
182
201
  end
183
202
 
203
+ def optional_phase_unavailable(phase, handler, results)
204
+ contract = Helpers::Constants::OPTIONAL_PHASES[phase]
205
+ return unless contract
206
+ return { status: :unavailable, reason: :missing_handler } unless handler
207
+
208
+ missing = contract[:requires].select do |dependency|
209
+ result = results[dependency]
210
+ !result.is_a?(Hash) || result.empty? || result[:success] == false ||
211
+ Helpers::Constants::INCOMPLETE_PHASE_STATUSES.include?(result[:status])
212
+ end
213
+ return if missing.empty?
214
+
215
+ { status: :deferred, reason: :missing_prerequisites, phases: missing }
216
+ end
217
+
184
218
  def log_tick_complete(state, results, phases, total_elapsed, skipped)
185
219
  skipped_suffix = skipped.empty? ? '' : " skipped=#{skipped}"
186
220
  log.debug "[tick] ##{state.tick_count} complete | mode=#{state.mode} " \
@@ -194,19 +228,26 @@ module Legion
194
228
 
195
229
  skipped = phases - results.keys
196
230
  failed = failed_dream_phases(results)
197
- state.record_dream_completed
198
231
  state.transition_to(:dormant)
199
232
  if skipped.empty? && failed.empty?
233
+ state.record_dream_completed
200
234
  log.info '[tick] dream cycle complete; backing off to dormant'
201
235
  else
236
+ state.record_dream_deferred
202
237
  log.info "[tick] dream cycle deferred; backing off to dormant skipped=#{skipped} failed=#{failed}"
203
238
  end
204
239
  end
205
240
 
206
241
  def failed_dream_phases(results)
207
242
  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)
243
+ unless result.is_a?(Hash) && !result.empty?
244
+ failed << phase
245
+ next
246
+ end
247
+
248
+ status = result[:status]
249
+ incomplete = Helpers::Constants::INCOMPLETE_PHASE_STATUSES.include?(status)
250
+ failed << phase if incomplete || result[:success] == false
210
251
  end
211
252
  end
212
253
 
@@ -221,7 +262,8 @@ module Legion
221
262
  end
222
263
 
223
264
  def dream_backoff_elapsed?(state)
224
- state.seconds_since_dream_completed >= Helpers::Constants::DREAM_BACKOFF_INTERVAL
265
+ state.seconds_since_dream_completed >= Helpers::Constants::DREAM_BACKOFF_INTERVAL &&
266
+ state.seconds_since_dream_deferred >= Helpers::Constants::DREAM_BACKOFF_INTERVAL
225
267
  end
226
268
 
227
269
  def tick_state
@@ -3,7 +3,7 @@
3
3
  module Legion
4
4
  module Extensions
5
5
  module Tick
6
- VERSION = '0.1.17'
6
+ VERSION = '0.1.18'
7
7
  end
8
8
  end
9
9
  end
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'open3'
3
4
  require 'legion/extensions/tick/client'
4
5
 
5
6
  RSpec.describe Legion::Extensions::Tick::Client do
@@ -16,9 +17,18 @@ RSpec.describe Legion::Extensions::Tick::Client do
16
17
  expect(client.tick_status[:mode]).to eq(:sentinel)
17
18
  end
18
19
 
19
- it 'runs a full active tick with all 16 phases' do
20
- client = described_class.new(mode: :full_active)
21
- result = client.execute_tick(signals: [{ salience: 0.9, source_type: :human_direct }])
22
- expect(result[:phases_executed].size).to eq(16)
20
+ it 'executes standalone without the framework or its test helper supplying logging' do
21
+ script = <<~RUBY
22
+ require 'legion/extensions/tick/client'
23
+ result = Legion::Extensions::Tick::Client.new.execute_tick(
24
+ phase_handlers: { memory_consolidation: ->(**) { { consolidated: true } } }
25
+ )
26
+ abort 'consolidation did not execute' unless result.dig(:results, :memory_consolidation, :consolidated)
27
+ puts 'standalone consolidation completed'
28
+ RUBY
29
+ output, status = Open3.capture2e(RbConfig.ruby, '-Ilib', '-e', script)
30
+
31
+ expect(status.success?).to be(true), output
32
+ expect(output).to include('standalone consolidation completed')
23
33
  end
24
34
  end
@@ -12,11 +12,6 @@ RSpec.describe Legion::Extensions::Tick::Helpers::Constants do
12
12
  expect(phases.size).to eq(5)
13
13
  expect(phases).to include(:sensory_processing, :memory_retrieval)
14
14
  end
15
-
16
- it 'returns all 16 phases for full_active' do
17
- phases = described_class.phases_for_mode(:full_active)
18
- expect(phases.size).to eq(16)
19
- end
20
15
  end
21
16
 
22
17
  describe '.tick_budget' do
@@ -33,10 +28,6 @@ RSpec.describe Legion::Extensions::Tick::Helpers::Constants do
33
28
  end
34
29
  end
35
30
 
36
- it 'defines exactly 16 phases' do
37
- expect(described_class::PHASES.size).to eq(16)
38
- end
39
-
40
31
  it 'defines phase budgets for all active phases' do
41
32
  described_class::PHASES.each do |phase|
42
33
  expect(described_class::PHASE_BUDGETS).to have_key(phase)
@@ -47,17 +38,9 @@ RSpec.describe Legion::Extensions::Tick::Helpers::Constants do
47
38
  it 'includes dormant_active' do
48
39
  expect(described_class::MODES).to include(:dormant_active)
49
40
  end
50
-
51
- it 'has exactly 4 modes' do
52
- expect(described_class::MODES.size).to eq(4)
53
- end
54
41
  end
55
42
 
56
43
  describe 'DREAM_PHASES' do
57
- it 'defines 10 dream phases' do
58
- expect(described_class::DREAM_PHASES.size).to eq(10)
59
- end
60
-
61
44
  it 'includes partner_reflection' do
62
45
  expect(described_class::DREAM_PHASES).to include(:partner_reflection)
63
46
  end
@@ -91,22 +74,4 @@ RSpec.describe Legion::Extensions::Tick::Helpers::Constants do
91
74
  expect(described_class.tick_budget(:dormant_active)).to eq(described_class::DREAM_TICK_BUDGET)
92
75
  end
93
76
  end
94
-
95
- describe 'dream thresholds' do
96
- it 'defines DREAM_IDLE_THRESHOLD as 1800' do
97
- expect(described_class::DREAM_IDLE_THRESHOLD).to eq(1800)
98
- end
99
-
100
- it 'defines SENTINEL_TO_DREAM_THRESHOLD as 600' do
101
- expect(described_class::SENTINEL_TO_DREAM_THRESHOLD).to eq(600)
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
111
- end
112
77
  end
@@ -12,6 +12,10 @@ RSpec.describe Legion::Extensions::Tick::Runners::Orchestrator do
12
12
  end
13
13
 
14
14
  describe '#execute_tick' do
15
+ let(:dream_handlers) do
16
+ Legion::Extensions::Tick::Helpers::Constants::DREAM_PHASES.to_h { |phase| [phase, ->(**) { { status: :ok } }] }
17
+ end
18
+
15
19
  before do
16
20
  allow(client.send(:tick_state)).to receive(:seconds_since_signal).and_return(60.0)
17
21
  end
@@ -28,35 +32,6 @@ RSpec.describe Legion::Extensions::Tick::Runners::Orchestrator do
28
32
  expect(r2[:tick_number]).to eq(r1[:tick_number] + 1)
29
33
  end
30
34
 
31
- it 'uses phase handlers when provided' do
32
- handler_called = false
33
- handlers = {
34
- memory_consolidation: lambda { |**|
35
- handler_called = true
36
- { status: :ok }
37
- }
38
- }
39
- client.execute_tick(phase_handlers: handlers)
40
- expect(handler_called).to be true
41
- end
42
-
43
- it 'passes extra execution context through to phase handlers' do
44
- seen = nil
45
- handlers = {
46
- memory_consolidation: lambda { |partner_observations: nil, **|
47
- seen = partner_observations
48
- { status: :ok }
49
- }
50
- }
51
-
52
- client.execute_tick(
53
- phase_handlers: handlers,
54
- partner_observations: [{ identity: 'partner-1' }]
55
- )
56
-
57
- expect(seen).to eq([{ identity: 'partner-1' }])
58
- end
59
-
60
35
  it 'reports no_handler for unhandled phases' do
61
36
  result = client.execute_tick
62
37
  expect(result[:results][:memory_consolidation][:status]).to eq(:no_handler)
@@ -68,10 +43,46 @@ RSpec.describe Legion::Extensions::Tick::Runners::Orchestrator do
68
43
  expect(result[:mode]).to eq(:sentinel)
69
44
  end
70
45
 
46
+ it 'runs action selection for the first human signal without a second signal' do
47
+ result = client.execute_tick(
48
+ signals: [{ source_type: :human_direct, salience: 0.1 }],
49
+ phase_handlers: { action_selection: ->(**) { { status: :selected } } }
50
+ )
51
+
52
+ expect(result[:mode]).to eq(:full_active)
53
+ expect(result[:results][:action_selection]).to eq(status: :selected)
54
+ expect(client.execute_tick[:mode]).to eq(:full_active)
55
+ end
56
+
57
+ it 'promotes a dormant agent immediately for a high-salience non-human signal' do
58
+ result = client.execute_tick(signals: [{ source_type: :sensor, salience: 0.9 }])
59
+
60
+ expect(result[:mode]).to eq(:full_active)
61
+ expect(result[:phases_executed]).to include(:action_selection)
62
+ end
63
+
64
+ it 'interrupts a dream with full processing for a low-salience human signal' do
65
+ dreaming_client = Legion::Extensions::Tick::Client.new(mode: :dormant_active)
66
+ result = dreaming_client.execute_tick(signals: [{ source_type: :human_direct, salience: 0.1 }])
67
+
68
+ expect(result[:mode]).to eq(:full_active)
69
+ expect(result[:phases_executed]).to include(:action_selection)
70
+ expect(result[:phases_executed]).not_to include(:dream_narration)
71
+ expect(dreaming_client.tick_status[:last_dream_completed_at]).to be_nil
72
+ end
73
+
74
+ it 'honors a forced mode for one tick before allowing human promotion' do
75
+ client.set_mode(mode: :sentinel)
76
+ signals = [{ source_type: :human_direct, salience: 0.1 }]
77
+
78
+ expect(client.execute_tick(signals: signals)[:phases_executed]).not_to include(:action_selection)
79
+ expect(client.execute_tick(signals: signals)[:phases_executed]).to include(:action_selection)
80
+ end
81
+
71
82
  it 'completes one dormant_active dream cycle and backs off to dormant' do
72
83
  client.set_mode(mode: :dormant_active)
73
84
 
74
- result = client.execute_tick
85
+ result = client.execute_tick(phase_handlers: dream_handlers)
75
86
 
76
87
  expect(result[:phases_executed]).to eq(Legion::Extensions::Tick::Helpers::Constants::DREAM_PHASES)
77
88
  expect(result[:mode]).to eq(:dormant)
@@ -83,7 +94,7 @@ RSpec.describe Legion::Extensions::Tick::Runners::Orchestrator do
83
94
  allow(state).to receive(:seconds_since_signal).and_return(1801.0)
84
95
 
85
96
  client.set_mode(mode: :dormant_active)
86
- client.execute_tick
97
+ client.execute_tick(phase_handlers: dream_handlers)
87
98
  result = client.execute_tick
88
99
 
89
100
  expect(result[:mode]).to eq(:dormant)
@@ -94,14 +105,13 @@ RSpec.describe Legion::Extensions::Tick::Runners::Orchestrator do
94
105
  client.set_mode(mode: :dormant_active)
95
106
 
96
107
  result = client.execute_tick(
97
- phase_handlers: {
98
- dream_narration: ->(**) { { status: :error } }
99
- }
108
+ phase_handlers: dream_handlers.merge(dream_narration: ->(**) { { status: :error } })
100
109
  )
101
110
 
102
111
  expect(result[:mode]).to eq(:dormant)
103
112
  expect(result[:results][:dream_narration][:status]).to eq(:error)
104
- expect(client.tick_status[:last_dream_completed_at]).not_to be_nil
113
+ expect(client.tick_status[:last_dream_completed_at]).to be_nil
114
+ expect(client.tick_status[:last_dream_deferred_at]).not_to be_nil
105
115
  end
106
116
 
107
117
  it 'backs off to dormant when the dream tick budget is exhausted before all phases run' do
@@ -115,12 +125,156 @@ RSpec.describe Legion::Extensions::Tick::Runners::Orchestrator do
115
125
  expect(result[:mode]).to eq(:dormant)
116
126
  expect(result[:phases_executed]).to be_empty
117
127
  expect(result[:phases_skipped]).to eq(constants::DREAM_PHASES)
118
- expect(client.tick_status[:last_dream_completed_at]).not_to be_nil
128
+ expect(client.tick_status[:last_dream_completed_at]).to be_nil
129
+ expect(client.tick_status[:last_dream_deferred_at]).not_to be_nil
130
+ end
131
+
132
+ it 'backs off an unhandled dream without claiming completion' do
133
+ state = client.send(:tick_state)
134
+ allow(state).to receive(:seconds_since_signal).and_return(1801.0)
135
+ client.set_mode(mode: :dormant_active)
136
+
137
+ client.execute_tick
138
+ expect(client.tick_status[:last_dream_completed_at]).to be_nil
139
+ expect(client.execute_tick[:phases_executed]).to eq([:memory_consolidation])
140
+ end
141
+
142
+ it 'does not claim completion when a handler reports unsuccessful work without a status' do
143
+ client.set_mode(mode: :dormant_active)
144
+ client.execute_tick(phase_handlers: dream_handlers.merge(dream_narration: ->(**) { { success: false } }))
145
+
146
+ expect(client.tick_status[:last_dream_completed_at]).to be_nil
147
+ expect(client.tick_status[:last_dream_deferred_at]).not_to be_nil
148
+ end
149
+
150
+ it 'does not immediately replay earlier dream effects when a later handler raises' do
151
+ allow(client.send(:tick_state)).to receive(:seconds_since_signal).and_return(1801.0)
152
+ effects = []
153
+ handlers = dream_handlers.merge(
154
+ memory_audit: ->(**) { effects << :audit },
155
+ association_walk: ->(**) { raise 'association failure' }
156
+ )
157
+ client.set_mode(mode: :dormant_active)
158
+
159
+ expect { client.execute_tick(phase_handlers: handlers) }.to raise_error('association failure')
160
+ expect(client.tick_status[:last_dream_completed_at]).to be_nil
161
+ client.execute_tick(phase_handlers: handlers)
162
+ expect(effects).to eq([:audit])
163
+ end
164
+
165
+ it 'keeps budget accounting independent of backwards wall-clock adjustments' do
166
+ allow(Time).to receive(:now).and_return(Time.utc(2026, 10, 29))
167
+ result = client.execute_tick(phase_handlers: {
168
+ memory_consolidation: lambda { |**|
169
+ allow(Time).to receive(:now).and_return(Time.utc(2026, 10, 28))
170
+ { status: :ok }
171
+ }
172
+ })
173
+
174
+ expect(result[:elapsed]).to be >= 0.0
175
+ expect(result[:elapsed]).to be < 1.0
176
+ end
177
+
178
+ it 'does not count an empty dream result as a completed cycle' do
179
+ client.set_mode(mode: :dormant_active)
180
+ client.execute_tick(phase_handlers: dream_handlers.merge(memory_audit: ->(**) {}))
181
+
182
+ expect(client.tick_status[:last_dream_completed_at]).to be_nil
183
+ expect(client.tick_status[:last_dream_deferred_at]).not_to be_nil
184
+ end
185
+ end
186
+
187
+ describe 'optional phase scheduling' do
188
+ let(:signals) { [{ source_type: :human_direct, salience: 0.1 }] }
189
+
190
+ it 'does not activate optional work merely because a handler is available' do
191
+ effects = []
192
+ client.execute_tick(signals: signals, phase_handlers: { metacognition: ->(**) { effects << :snapshot } })
193
+
194
+ expect(effects).to be_empty
195
+ end
196
+
197
+ it 'makes workspace selection available to the subsequent action decision' do
198
+ handlers = {
199
+ sensory_processing: ->(**) { { candidates: [{ content: 'urgent request', salience: 0.9 }] } },
200
+ global_workspace: lambda { |prior_results:, **|
201
+ { selected: prior_results[:sensory_processing][:candidates].max_by { |candidate| candidate[:salience] } }
202
+ },
203
+ action_selection: ->(prior_results:, **) { { decision: prior_results.dig(:global_workspace, :selected, :content) } }
204
+ }
205
+ result = client.execute_tick(signals: signals, phase_handlers: handlers, enabled_optional_phases: [:global_workspace])
206
+
207
+ expect(result.dig(:results, :action_selection, :decision)).to eq('urgent request')
208
+ end
209
+
210
+ it 'runs metacognition once after reflection even if the opt-in is repeated' do
211
+ observations = []
212
+ handlers = {
213
+ post_tick_reflection: ->(**) { { cognitive_health: 0.8 } },
214
+ metacognition: lambda { |prior_results:, **|
215
+ observations << prior_results.dig(:post_tick_reflection, :cognitive_health)
216
+ { status: :completed }
217
+ }
218
+ }
219
+ client.execute_tick(signals: signals, phase_handlers: handlers, enabled_optional_phases: %i[metacognition metacognition])
220
+
221
+ expect(observations).to eq([0.8])
222
+ end
223
+
224
+ it 'does not run full-active optional work during a forced sentinel tick' do
225
+ effects = []
226
+ client.set_mode(mode: :sentinel)
227
+ result = client.execute_tick(signals: signals, enabled_optional_phases: [:metacognition],
228
+ phase_handlers: { metacognition: ->(**) { effects << :snapshot } })
229
+
230
+ expect(effects).to be_empty
231
+ expect(result[:phases_scheduled]).not_to include(:metacognition)
232
+ end
233
+
234
+ it 'reports an unavailable optional capability when its handler is absent' do
235
+ result = client.execute_tick(signals: signals, enabled_optional_phases: [:metacognition])
236
+
237
+ expect(result.dig(:results, :metacognition)).to include(status: :unavailable, reason: :missing_handler)
238
+ end
239
+
240
+ it 'defers optional work rather than consuming a failed prerequisite' do
241
+ effects = []
242
+ result = client.execute_tick(
243
+ signals: signals, enabled_optional_phases: [:metacognition],
244
+ phase_handlers: {
245
+ post_tick_reflection: ->(**) { { success: false } },
246
+ metacognition: ->(**) { effects << :snapshot }
247
+ }
248
+ )
249
+
250
+ expect(effects).to be_empty
251
+ expect(result.dig(:results, :metacognition)).to include(status: :deferred, phases: [:post_tick_reflection])
252
+ end
253
+
254
+ it 'rejects unknown phase names before advancing tick state' do
255
+ expect { client.execute_tick(enabled_optional_phases: [:dream_cycle]) }.to raise_error(ArgumentError)
256
+ expect(client.tick_status[:tick_count]).to eq(0)
257
+ end
258
+
259
+ it 'rejects a scalar opt-in instead of silently ignoring it' do
260
+ expect { client.execute_tick(enabled_optional_phases: :metacognition) }.to raise_error(ArgumentError)
261
+ expect(client.tick_status[:tick_count]).to eq(0)
262
+ end
263
+
264
+ it 'reports budget-skipped optional work without invoking it' do
265
+ allow(Legion::Extensions::Tick::Helpers::Constants).to receive(:tick_budget).and_return(0.0)
266
+ effects = []
267
+ result = client.execute_tick(signals: signals, enabled_optional_phases: [:metacognition],
268
+ phase_handlers: { metacognition: ->(**) { effects << :snapshot } })
269
+
270
+ expect(effects).to be_empty
271
+ expect(result[:phases_skipped]).to include(:metacognition)
272
+ expect(result[:results]).not_to have_key(:metacognition)
119
273
  end
120
274
  end
121
275
 
122
276
  describe '#evaluate_mode_transition' do
123
- it 'promotes dormant to sentinel on any signal' do
277
+ it 'promotes dormant to sentinel on a low-salience non-human signal' do
124
278
  result = client.evaluate_mode_transition(signals: [{ salience: 0.1 }])
125
279
  expect(result[:transitioned]).to be true
126
280
  expect(result[:new_mode]).to eq(:sentinel)
@@ -176,11 +330,11 @@ RSpec.describe Legion::Extensions::Tick::Runners::Orchestrator do
176
330
  expect(result[:new_mode]).to eq(:sentinel)
177
331
  end
178
332
 
179
- it 'transitions dormant_active -> sentinel on high-salience signal' do
333
+ it 'transitions dormant_active -> full_active on high-salience signal' do
180
334
  client.set_mode(mode: :dormant_active)
181
335
  result = client.evaluate_mode_transition(signals: [{ salience: 0.9 }])
182
336
  expect(result[:transitioned]).to be true
183
- expect(result[:new_mode]).to eq(:sentinel)
337
+ expect(result[:new_mode]).to eq(:full_active)
184
338
  end
185
339
 
186
340
  it 'transitions dormant_active -> dormant when dream_complete: true with no signals' do
@@ -255,12 +409,6 @@ RSpec.describe Legion::Extensions::Tick::Runners::Orchestrator do
255
409
  expect(result[:mode]).to eq(:full_active)
256
410
  end
257
411
 
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
-
264
412
  it 'rejects invalid mode' do
265
413
  result = client.set_mode(mode: :invalid)
266
414
  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.17
4
+ version: 0.1.18
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity