lex-tick 0.1.16 → 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: 94918234bd1b19854887b794867550d1a6252f74ea4ad10dd6009b6df2797c5b
4
- data.tar.gz: df175d240f2a5816e9b7be4269aa12880051e1bbe5bdf6f761731cca585b0e10
3
+ metadata.gz: 86c832a54da11de90ede212d84cfd65a4fed13510e0d4cf7307e3127723fdaf9
4
+ data.tar.gz: 232d3c7e96e3f9c78d86e3452e2f50429a5c09eee1336643d87b39df5c06a5f2
5
5
  SHA512:
6
- metadata.gz: 291b85fa4cfe572c00bb279026569c08691daf1233d7900ffe619e6c4f8d15b3cc9241d18f98aa8ef22715a0438a78a1239919181decf4463f8ab26e13c708cc
7
- data.tar.gz: 56b3a74946173477cbd2d3d85a6b35ff15cc61472b9065bade72af63dcbdab5a8392ceaf50a54ca7e4397db01142cf6d4ee702786d60d938bbd7fa2ff541f954
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
@@ -100,15 +105,14 @@ module Legion
100
105
 
101
106
  previous = tick_state.mode
102
107
  tick_state.transition_to(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})"
108
+ @mode_forced = true
109
+ log.info "[tick] mode forced: #{previous} -> #{mode}"
106
110
  { mode: mode }
107
111
  end
108
112
 
109
113
  private
110
114
 
111
- def execute_tick_impl(signals:, phase_handlers:, **context)
115
+ def execute_tick_impl(signals:, phase_handlers:, enabled_optional_phases:, **context)
112
116
  state = tick_state
113
117
  state.increment_tick
114
118
 
@@ -122,15 +126,14 @@ module Legion
122
126
  # @mode_forced is set by set_mode and cleared here so the force lasts exactly one cycle.
123
127
  if @mode_forced
124
128
  log.debug "[tick] ##{state.tick_count} skipping mode evaluation (mode forced to #{state.mode})"
125
- @mode_forced = false
126
- @force_expires_at = nil
129
+ @mode_forced = false
127
130
  else
128
131
  evaluate_mode_transition(signals: signals)
129
132
  end
130
133
 
131
- phases = Helpers::Constants.phases_for_mode(state.mode)
134
+ phases = Helpers::Constants.phases_for_mode(state.mode, enabled_optional_phases: enabled_optional_phases)
132
135
  budget = Helpers::Constants.tick_budget(state.mode)
133
- start_time = Time.now.utc
136
+ start_time = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
134
137
  ctx = {
135
138
  budget: budget,
136
139
  start_time: start_time,
@@ -141,18 +144,26 @@ module Legion
141
144
  results = run_phases(phases, state, ctx)
142
145
  finish_dream_cycle(state, phases, results)
143
146
 
144
- total_elapsed = Time.now.utc - start_time
147
+ total_elapsed = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - start_time
145
148
  skipped = phases - results.keys
146
149
  log_tick_complete(state, results, phases, total_elapsed, skipped)
147
150
 
148
151
  {
149
- tick_number: state.tick_count,
150
- mode: state.mode,
151
- phases_executed: results.keys,
152
- phases_skipped: skipped,
153
- results: results,
154
- 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
155
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
156
167
  end
157
168
 
158
169
  def run_phases(phases, state, ctx)
@@ -161,7 +172,7 @@ module Legion
161
172
  start_time = ctx[:start_time]
162
173
  log.debug "[tick] ##{state.tick_count} running #{phases.size} phases with #{budget}s budget"
163
174
  phases.each do |phase|
164
- elapsed = Time.now.utc - start_time
175
+ elapsed = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - start_time
165
176
  if elapsed >= budget
166
177
  log.debug "[tick] ##{state.tick_count} budget exhausted at #{elapsed.round(3)}s, skipping remaining phases"
167
178
  break
@@ -175,14 +186,35 @@ module Legion
175
186
  end
176
187
 
177
188
  def run_single_phase(phase, handler, state, signals, results, **context)
178
- phase_start = Time.now.utc
179
- result = handler ? handler.call(state: state, signals: signals, prior_results: results, **context) : { status: :no_handler }
180
- 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)
181
198
  status = result.is_a?(Hash) ? (result[:status] || :ok) : :ok
182
199
  log.debug "[tick] ##{state.tick_count} phase=#{phase} status=#{status} (#{phase_elapsed}ms)"
183
200
  result
184
201
  end
185
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
+
186
218
  def log_tick_complete(state, results, phases, total_elapsed, skipped)
187
219
  skipped_suffix = skipped.empty? ? '' : " skipped=#{skipped}"
188
220
  log.debug "[tick] ##{state.tick_count} complete | mode=#{state.mode} " \
@@ -196,19 +228,26 @@ module Legion
196
228
 
197
229
  skipped = phases - results.keys
198
230
  failed = failed_dream_phases(results)
199
- state.record_dream_completed
200
231
  state.transition_to(:dormant)
201
232
  if skipped.empty? && failed.empty?
233
+ state.record_dream_completed
202
234
  log.info '[tick] dream cycle complete; backing off to dormant'
203
235
  else
236
+ state.record_dream_deferred
204
237
  log.info "[tick] dream cycle deferred; backing off to dormant skipped=#{skipped} failed=#{failed}"
205
238
  end
206
239
  end
207
240
 
208
241
  def failed_dream_phases(results)
209
242
  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)
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
212
251
  end
213
252
  end
214
253
 
@@ -223,7 +262,8 @@ module Legion
223
262
  end
224
263
 
225
264
  def dream_backoff_elapsed?(state)
226
- 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
227
267
  end
228
268
 
229
269
  def tick_state
@@ -3,7 +3,7 @@
3
3
  module Legion
4
4
  module Extensions
5
5
  module Tick
6
- VERSION = '0.1.16'
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
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.16
4
+ version: 0.1.18
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity