lex-tick 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 4a3808a8152389198268ec0a2064aee1a4acdcf8fc7aa5593118e1cd7f82deda
4
+ data.tar.gz: 770f22284ee087b9319597ebe194b34bcc4e1b17b901db13f4bc8d45b38a6d3c
5
+ SHA512:
6
+ metadata.gz: 80498e4cfb7859876f8efb0e72ad89bad390f307d56f7a27dea617092f946d828db57522591af4629a3fe00e6c1ae279e7effca892a35cb5d14fe4085ad5eb3d
7
+ data.tar.gz: f9a62ee7072446e4c0caf5b55c05fff8d12323ab3619fb7d9c923e0e252649ec9acab3d287f46a1358b6298ecb7285e519499838bf6e6c0b3c1f66b36c7662a6
data/Gemfile ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ gemspec
6
+
7
+ gem 'rspec', '~> 3.13'
8
+ gem 'rubocop', '~> 1.75', require: false
9
+
10
+ gem 'legion-gaia', path: '../../legion-gaia'
data/lex-tick.gemspec ADDED
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'lib/legion/extensions/tick/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'lex-tick'
7
+ spec.version = Legion::Extensions::Tick::VERSION
8
+ spec.authors = ['Esity']
9
+ spec.email = ['matthewdiverson@gmail.com']
10
+
11
+ spec.summary = 'LEX Tick'
12
+ spec.description = 'Atomic cognitive processing cycle (11 phases, 3 modes) for brain-modeled agentic AI'
13
+ spec.homepage = 'https://github.com/LegionIO/lex-tick'
14
+ spec.license = 'MIT'
15
+ spec.required_ruby_version = '>= 3.4'
16
+
17
+ spec.metadata['homepage_uri'] = spec.homepage
18
+ spec.metadata['source_code_uri'] = 'https://github.com/LegionIO/lex-tick'
19
+ spec.metadata['documentation_uri'] = 'https://github.com/LegionIO/lex-tick'
20
+ spec.metadata['changelog_uri'] = 'https://github.com/LegionIO/lex-tick'
21
+ spec.metadata['bug_tracker_uri'] = 'https://github.com/LegionIO/lex-tick/issues'
22
+ spec.metadata['rubygems_mfa_required'] = 'true'
23
+
24
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
25
+ Dir.glob('{lib,spec}/**/*') + %w[lex-tick.gemspec Gemfile]
26
+ end
27
+ spec.require_paths = ['lib']
28
+ spec.add_development_dependency 'legion-gaia'
29
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/actors/every'
4
+
5
+ module Legion
6
+ module Extensions
7
+ module Tick
8
+ module Actor
9
+ # Disabled: lex-cortex's Think actor replaces this.
10
+ # Cortex wires phase_handlers from all agentic extensions
11
+ # and calls execute_tick with real handlers instead of empty ones.
12
+ # To use tick standalone (without cortex), re-enable this actor.
13
+ class Tick < Legion::Extensions::Actors::Every
14
+ def initialize(**opts)
15
+ return unless enabled?
16
+
17
+ super
18
+ end
19
+
20
+ def runner_class
21
+ Legion::Extensions::Tick::Runners::Orchestrator
22
+ end
23
+
24
+ def runner_function
25
+ 'execute_tick'
26
+ end
27
+
28
+ def enabled?
29
+ !Legion::Extensions.const_defined?(:Cortex)
30
+ end
31
+
32
+ def time
33
+ 1
34
+ end
35
+
36
+ def run_now?
37
+ true
38
+ end
39
+
40
+ def use_runner?
41
+ false
42
+ end
43
+
44
+ def check_subtask?
45
+ false
46
+ end
47
+
48
+ def generate_task?
49
+ false
50
+ end
51
+
52
+ def args
53
+ { signals: [], phase_handlers: {} }
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/tick/helpers/constants'
4
+ require 'legion/extensions/tick/helpers/state'
5
+ require 'legion/extensions/tick/runners/orchestrator'
6
+
7
+ module Legion
8
+ module Extensions
9
+ module Tick
10
+ class Client
11
+ include Runners::Orchestrator
12
+
13
+ def initialize(mode: :dormant, **)
14
+ @tick_state = Helpers::State.new(mode: mode)
15
+ end
16
+
17
+ private
18
+
19
+ attr_reader :tick_state
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Tick
6
+ module Helpers
7
+ module Constants
8
+ # Tick modes
9
+ MODES = %i[dormant dormant_active sentinel full_active].freeze
10
+
11
+ # 12 phases of a full active tick
12
+ PHASES = %i[
13
+ sensory_processing
14
+ emotional_evaluation
15
+ memory_retrieval
16
+ identity_entropy_check
17
+ working_memory_integration
18
+ procedural_check
19
+ prediction_engine
20
+ mesh_interface
21
+ gut_instinct
22
+ action_selection
23
+ memory_consolidation
24
+ post_tick_reflection
25
+ ].freeze
26
+
27
+ # Phases for dream cycle (dormant_active mode)
28
+ DREAM_PHASES = %i[
29
+ memory_audit
30
+ association_walk
31
+ contradiction_resolution
32
+ identity_entropy_check
33
+ agenda_formation
34
+ consolidation_commit
35
+ dream_reflection
36
+ dream_narration
37
+ ].freeze
38
+
39
+ # Which phases run in each mode
40
+ MODE_PHASES = {
41
+ dormant: %i[memory_consolidation],
42
+ dormant_active: DREAM_PHASES,
43
+ sentinel: %i[sensory_processing emotional_evaluation memory_retrieval prediction_engine memory_consolidation],
44
+ full_active: PHASES
45
+ }.freeze
46
+
47
+ # Timing constants (in seconds)
48
+ ACTIVE_TIMEOUT = 300 # seconds without high-salience signal before demotion
49
+ SENTINEL_TIMEOUT = 3600 # seconds without any signal before demotion to dormant
50
+ DREAM_IDLE_THRESHOLD = 1800 # seconds dormant with no signal before entering dream cycle
51
+ SENTINEL_TO_DREAM_THRESHOLD = 600 # seconds sentinel with no signal before entering dream cycle
52
+ MAX_TICK_DURATION = 5.0 # hard ceiling for full active tick (seconds)
53
+ SENTINEL_TICK_BUDGET = 0.5 # time budget for sentinel tick
54
+ DORMANT_TICK_BUDGET = 0.2 # time budget for dormant tick
55
+ EMERGENCY_PROMOTION_BUDGET = 0.05 # max latency for emergency mode promotion
56
+
57
+ # Phase timing budgets (fraction of total tick time)
58
+ PHASE_BUDGETS = {
59
+ sensory_processing: 0.15,
60
+ emotional_evaluation: 0.10,
61
+ memory_retrieval: 0.20,
62
+ identity_entropy_check: 0.05,
63
+ working_memory_integration: 0.05,
64
+ procedural_check: 0.10,
65
+ prediction_engine: 0.15,
66
+ mesh_interface: 0.05,
67
+ gut_instinct: 0.05,
68
+ action_selection: 0.05,
69
+ memory_consolidation: 0.05,
70
+ post_tick_reflection: 0.05
71
+ }.freeze
72
+
73
+ # Salience thresholds for mode transitions
74
+ HIGH_SALIENCE_THRESHOLD = 0.7
75
+ EMERGENCY_TRIGGERS = %i[firmware_violation extinction_protocol].freeze
76
+
77
+ module_function
78
+
79
+ def phases_for_mode(mode)
80
+ MODE_PHASES.fetch(mode, PHASES)
81
+ end
82
+
83
+ def tick_budget(mode)
84
+ case mode
85
+ when :dormant then DORMANT_TICK_BUDGET
86
+ when :dormant_active then Float::INFINITY
87
+ when :sentinel then SENTINEL_TICK_BUDGET
88
+ else MAX_TICK_DURATION
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Tick
6
+ module Helpers
7
+ class State
8
+ attr_reader :mode, :tick_count, :last_signal_at, :last_high_salience_at,
9
+ :phase_results, :current_phase, :mode_history
10
+
11
+ def initialize(mode: :dormant)
12
+ @mode = mode
13
+ @tick_count = 0
14
+ @last_signal_at = nil
15
+ @last_high_salience_at = nil
16
+ @phase_results = {}
17
+ @current_phase = nil
18
+ @mode_history = [{ mode: mode, at: Time.now.utc }]
19
+ end
20
+
21
+ def record_signal(salience: 0.0)
22
+ @last_signal_at = Time.now.utc
23
+ @last_high_salience_at = Time.now.utc if salience >= Constants::HIGH_SALIENCE_THRESHOLD
24
+ end
25
+
26
+ def record_phase(phase, result)
27
+ @current_phase = phase
28
+ @phase_results[phase] = result
29
+ end
30
+
31
+ def increment_tick
32
+ @tick_count += 1
33
+ @phase_results = {}
34
+ @current_phase = nil
35
+ end
36
+
37
+ def transition_to(new_mode)
38
+ return if new_mode == @mode
39
+
40
+ @mode = new_mode
41
+ @mode_history << { mode: new_mode, at: Time.now.utc }
42
+ @mode_history.shift while @mode_history.size > 50
43
+ end
44
+
45
+ def seconds_since_signal
46
+ return Float::INFINITY unless @last_signal_at
47
+
48
+ Time.now.utc - @last_signal_at
49
+ end
50
+
51
+ def seconds_since_high_salience
52
+ return Float::INFINITY unless @last_high_salience_at
53
+
54
+ Time.now.utc - @last_high_salience_at
55
+ end
56
+
57
+ def to_h
58
+ {
59
+ mode: @mode,
60
+ tick_count: @tick_count,
61
+ current_phase: @current_phase,
62
+ last_signal_at: @last_signal_at,
63
+ last_high_salience_at: @last_high_salience_at,
64
+ phases_completed: @phase_results.keys
65
+ }
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Tick
6
+ module Runners
7
+ module Orchestrator
8
+ include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers) &&
9
+ Legion::Extensions::Helpers.const_defined?(:Lex)
10
+
11
+ def execute_tick(signals: [], phase_handlers: {}, **)
12
+ state = tick_state
13
+ state.increment_tick
14
+
15
+ max_salience = signals.map { |s| s.is_a?(Hash) ? (s[:salience] || 0.0) : 0.0 }.max || 0.0
16
+ state.record_signal(salience: max_salience) unless signals.empty?
17
+
18
+ Legion::Logging.debug "[tick] ##{state.tick_count} starting | mode=#{state.mode} signals=#{signals.size} max_salience=#{max_salience.round(2)}"
19
+
20
+ transition = evaluate_mode_transition(signals: signals)
21
+ if transition[:transitioned]
22
+ Legion::Logging.info "[tick] mode transition: #{transition[:previous_mode]} -> #{transition[:new_mode]} (#{transition[:reason]})"
23
+ end
24
+
25
+ phases = Helpers::Constants.phases_for_mode(state.mode)
26
+ budget = Helpers::Constants.tick_budget(state.mode)
27
+ start_time = Time.now.utc
28
+ ctx = { budget: budget, start_time: start_time, phase_handlers: phase_handlers, signals: signals }
29
+ results = run_phases(phases, state, ctx)
30
+
31
+ total_elapsed = Time.now.utc - start_time
32
+ skipped = phases - results.keys
33
+ log_tick_complete(state, results, phases, total_elapsed, skipped)
34
+
35
+ {
36
+ tick_number: state.tick_count,
37
+ mode: state.mode,
38
+ phases_executed: results.keys,
39
+ phases_skipped: skipped,
40
+ results: results,
41
+ elapsed: total_elapsed
42
+ }
43
+ end
44
+
45
+ def evaluate_mode_transition(signals: [], emergency: nil, dream_complete: false, **) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
46
+ state = tick_state
47
+ previous_mode = state.mode
48
+
49
+ # Emergency promotion
50
+ if emergency && Helpers::Constants::EMERGENCY_TRIGGERS.include?(emergency)
51
+ Legion::Logging.warn "[tick] emergency promotion triggered: #{emergency}"
52
+ state.transition_to(:full_active)
53
+ return { transitioned: true, new_mode: :full_active, previous_mode: previous_mode, reason: :emergency }
54
+ end
55
+
56
+ # Check signal-based promotions
57
+ max_salience = signals.map { |s| s.is_a?(Hash) ? (s[:salience] || 0.0) : 0.0 }.max || 0.0
58
+ has_human = signals.any? { |s| s.is_a?(Hash) && s[:source_type] == :human_direct }
59
+
60
+ new_mode = case state.mode
61
+ when :dormant
62
+ if signals.any?
63
+ :sentinel
64
+ elsif state.seconds_since_signal >= Helpers::Constants::DREAM_IDLE_THRESHOLD
65
+ :dormant_active
66
+ else
67
+ :dormant
68
+ end
69
+ when :dormant_active
70
+ if max_salience >= Helpers::Constants::HIGH_SALIENCE_THRESHOLD || has_human
71
+ :sentinel
72
+ elsif dream_complete
73
+ :dormant
74
+ else
75
+ :dormant_active
76
+ end
77
+ when :sentinel
78
+ if has_human || max_salience >= Helpers::Constants::HIGH_SALIENCE_THRESHOLD
79
+ :full_active
80
+ elsif state.seconds_since_signal >= Helpers::Constants::SENTINEL_TO_DREAM_THRESHOLD
81
+ :dormant_active
82
+ elsif state.seconds_since_signal >= Helpers::Constants::SENTINEL_TIMEOUT
83
+ :dormant
84
+ else
85
+ :sentinel
86
+ end
87
+ when :full_active
88
+ if state.seconds_since_high_salience >= Helpers::Constants::ACTIVE_TIMEOUT
89
+ :sentinel
90
+ else
91
+ :full_active
92
+ end
93
+ end
94
+
95
+ if new_mode == state.mode
96
+ { transitioned: false, current_mode: state.mode }
97
+ else
98
+ state.transition_to(new_mode)
99
+ Legion::Logging.info "[tick] mode transition: #{previous_mode} -> #{new_mode} (threshold)"
100
+ { transitioned: true, new_mode: new_mode, previous_mode: previous_mode, reason: :threshold }
101
+ end
102
+ end
103
+
104
+ def tick_status(**)
105
+ status = tick_state.to_h
106
+ Legion::Logging.debug "[tick] status query: mode=#{status[:mode]} tick_count=#{status[:tick_count]}"
107
+ status
108
+ end
109
+
110
+ def set_mode(mode:, **)
111
+ unless Helpers::Constants::MODES.include?(mode)
112
+ Legion::Logging.warn "[tick] invalid mode requested: #{mode}"
113
+ return { error: :invalid_mode, valid_modes: Helpers::Constants::MODES }
114
+ end
115
+
116
+ previous = tick_state.mode
117
+ tick_state.transition_to(mode)
118
+ Legion::Logging.info "[tick] mode forced: #{previous} -> #{mode}"
119
+ { mode: mode }
120
+ end
121
+
122
+ private
123
+
124
+ def run_phases(phases, state, ctx)
125
+ results = {}
126
+ budget = ctx[:budget]
127
+ start_time = ctx[:start_time]
128
+ Legion::Logging.debug "[tick] ##{state.tick_count} running #{phases.size} phases with #{budget}s budget"
129
+ phases.each do |phase|
130
+ elapsed = Time.now.utc - start_time
131
+ if elapsed >= budget
132
+ Legion::Logging.debug "[tick] ##{state.tick_count} budget exhausted at #{elapsed.round(3)}s, skipping remaining phases"
133
+ break
134
+ end
135
+
136
+ result = run_single_phase(phase, ctx[:phase_handlers][phase], state, ctx[:signals], results)
137
+ state.record_phase(phase, result)
138
+ results[phase] = result
139
+ end
140
+ results
141
+ end
142
+
143
+ def run_single_phase(phase, handler, state, signals, results)
144
+ phase_start = Time.now.utc
145
+ result = handler ? handler.call(state: state, signals: signals, prior_results: results) : { status: :no_handler }
146
+ phase_elapsed = ((Time.now.utc - phase_start) * 1000).round(1)
147
+ status = result.is_a?(Hash) ? (result[:status] || :ok) : :ok
148
+ Legion::Logging.debug "[tick] ##{state.tick_count} phase=#{phase} status=#{status} (#{phase_elapsed}ms)"
149
+ result
150
+ end
151
+
152
+ def log_tick_complete(state, results, phases, total_elapsed, skipped)
153
+ skipped_suffix = skipped.empty? ? '' : " skipped=#{skipped}"
154
+ Legion::Logging.info "[tick] ##{state.tick_count} complete | mode=#{state.mode} " \
155
+ "phases=#{results.size}/#{phases.size} " \
156
+ "elapsed=#{(total_elapsed * 1000).round(1)}ms#{skipped_suffix}"
157
+ end
158
+
159
+ def tick_state
160
+ @tick_state ||= Helpers::State.new
161
+ end
162
+ end
163
+ end
164
+ end
165
+ end
166
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Tick
6
+ VERSION = '0.1.0'
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/tick/version'
4
+ require 'legion/extensions/tick/helpers/constants'
5
+ require 'legion/extensions/tick/helpers/state'
6
+ require 'legion/extensions/tick/runners/orchestrator'
7
+
8
+ module Legion
9
+ module Extensions
10
+ module Tick
11
+ extend Legion::Extensions::Core if Legion::Extensions.const_defined? :Core
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Stub the framework actor base class since legionio gem is not available in test
4
+ module Legion
5
+ module Extensions
6
+ module Actors
7
+ class Every # rubocop:disable Lint/EmptyClass
8
+ end
9
+ end
10
+ end
11
+ end
12
+
13
+ # Intercept the require in the actor file so it doesn't fail
14
+ $LOADED_FEATURES << 'legion/extensions/actors/every'
15
+
16
+ require 'legion/extensions/tick/actors/tick'
17
+
18
+ RSpec.describe Legion::Extensions::Tick::Actor::Tick do
19
+ subject(:actor) { described_class.new }
20
+
21
+ describe '#initialize' do
22
+ context 'when Cortex is NOT defined' do
23
+ before { hide_const('Legion::Extensions::Cortex') }
24
+
25
+ it 'instantiates without error' do
26
+ expect { described_class.new }.not_to raise_error
27
+ end
28
+ end
29
+
30
+ context 'when Cortex IS defined' do
31
+ before { stub_const('Legion::Extensions::Cortex', Module.new) }
32
+
33
+ it 'instantiates without error (returns early, skips super)' do
34
+ expect { described_class.new }.not_to raise_error
35
+ end
36
+ end
37
+ end
38
+
39
+ describe '#enabled?' do
40
+ context 'when Cortex is NOT defined' do
41
+ before { hide_const('Legion::Extensions::Cortex') }
42
+
43
+ it 'returns truthy' do
44
+ expect(actor.enabled?).to be_truthy
45
+ end
46
+ end
47
+
48
+ context 'when Cortex IS defined' do
49
+ before { stub_const('Legion::Extensions::Cortex', Module.new) }
50
+
51
+ it 'returns falsey' do
52
+ expect(actor.enabled?).to be_falsey
53
+ end
54
+ end
55
+ end
56
+
57
+ describe '#runner_class' do
58
+ it 'returns the Orchestrator module' do
59
+ expect(actor.runner_class).to eq(Legion::Extensions::Tick::Runners::Orchestrator)
60
+ end
61
+ end
62
+
63
+ describe '#runner_function' do
64
+ it 'returns execute_tick' do
65
+ expect(actor.runner_function).to eq('execute_tick')
66
+ end
67
+ end
68
+
69
+ describe '#time' do
70
+ it 'returns 1' do
71
+ expect(actor.time).to eq(1)
72
+ end
73
+ end
74
+
75
+ describe '#run_now?' do
76
+ it 'returns true' do
77
+ expect(actor.run_now?).to be true
78
+ end
79
+ end
80
+
81
+ describe '#use_runner?' do
82
+ it 'returns false' do
83
+ expect(actor.use_runner?).to be false
84
+ end
85
+ end
86
+
87
+ describe '#check_subtask?' do
88
+ it 'returns false' do
89
+ expect(actor.check_subtask?).to be false
90
+ end
91
+ end
92
+
93
+ describe '#generate_task?' do
94
+ it 'returns false' do
95
+ expect(actor.generate_task?).to be false
96
+ end
97
+ end
98
+
99
+ describe '#args' do
100
+ it 'returns a hash with empty signals and phase_handlers' do
101
+ expect(actor.args).to eq({ signals: [], phase_handlers: {} })
102
+ end
103
+ end
104
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/tick/client'
4
+
5
+ RSpec.describe Legion::Extensions::Tick::Client do
6
+ it 'responds to orchestrator methods' do
7
+ client = described_class.new
8
+ expect(client).to respond_to(:execute_tick)
9
+ expect(client).to respond_to(:evaluate_mode_transition)
10
+ expect(client).to respond_to(:tick_status)
11
+ expect(client).to respond_to(:set_mode)
12
+ end
13
+
14
+ it 'accepts initial mode' do
15
+ client = described_class.new(mode: :sentinel)
16
+ expect(client.tick_status[:mode]).to eq(:sentinel)
17
+ end
18
+
19
+ it 'runs a full active tick with all 12 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(12)
23
+ end
24
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::Tick::Helpers::Constants do
4
+ describe '.phases_for_mode' do
5
+ it 'returns 1 phase for dormant' do
6
+ phases = described_class.phases_for_mode(:dormant)
7
+ expect(phases).to eq([:memory_consolidation])
8
+ end
9
+
10
+ it 'returns 5 phases for sentinel' do
11
+ phases = described_class.phases_for_mode(:sentinel)
12
+ expect(phases.size).to eq(5)
13
+ expect(phases).to include(:sensory_processing, :memory_retrieval)
14
+ end
15
+
16
+ it 'returns all 12 phases for full_active' do
17
+ phases = described_class.phases_for_mode(:full_active)
18
+ expect(phases.size).to eq(12)
19
+ end
20
+ end
21
+
22
+ describe '.tick_budget' do
23
+ it 'returns 0.2s for dormant' do
24
+ expect(described_class.tick_budget(:dormant)).to eq(0.2)
25
+ end
26
+
27
+ it 'returns 0.5s for sentinel' do
28
+ expect(described_class.tick_budget(:sentinel)).to eq(0.5)
29
+ end
30
+
31
+ it 'returns 5.0s for full_active' do
32
+ expect(described_class.tick_budget(:full_active)).to eq(5.0)
33
+ end
34
+ end
35
+
36
+ it 'defines exactly 12 phases' do
37
+ expect(described_class::PHASES.size).to eq(12)
38
+ end
39
+
40
+ it 'defines phase budgets for all active phases' do
41
+ described_class::PHASES.each do |phase|
42
+ expect(described_class::PHASE_BUDGETS).to have_key(phase)
43
+ end
44
+ end
45
+
46
+ describe 'MODES' do
47
+ it 'includes dormant_active' do
48
+ expect(described_class::MODES).to include(:dormant_active)
49
+ end
50
+
51
+ it 'has exactly 4 modes' do
52
+ expect(described_class::MODES.size).to eq(4)
53
+ end
54
+ end
55
+
56
+ describe 'DREAM_PHASES' do
57
+ it 'defines 8 dream phases' do
58
+ expect(described_class::DREAM_PHASES.size).to eq(8)
59
+ end
60
+
61
+ it 'includes all expected dream phase symbols' do
62
+ expected = %i[memory_audit association_walk contradiction_resolution
63
+ identity_entropy_check agenda_formation consolidation_commit
64
+ dream_reflection dream_narration]
65
+ expect(described_class::DREAM_PHASES).to eq(expected)
66
+ end
67
+ end
68
+
69
+ describe 'MODE_PHASES' do
70
+ it 'maps dormant_active to the 8 dream phases' do
71
+ expect(described_class::MODE_PHASES[:dormant_active]).to eq(described_class::DREAM_PHASES)
72
+ end
73
+ end
74
+
75
+ describe '.tick_budget' do
76
+ it 'returns Float::INFINITY for dormant_active' do
77
+ expect(described_class.tick_budget(:dormant_active)).to eq(Float::INFINITY)
78
+ end
79
+ end
80
+
81
+ describe 'dream thresholds' do
82
+ it 'defines DREAM_IDLE_THRESHOLD as 1800' do
83
+ expect(described_class::DREAM_IDLE_THRESHOLD).to eq(1800)
84
+ end
85
+
86
+ it 'defines SENTINEL_TO_DREAM_THRESHOLD as 600' do
87
+ expect(described_class::SENTINEL_TO_DREAM_THRESHOLD).to eq(600)
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::Tick::Helpers::State do
4
+ let(:state) { described_class.new }
5
+
6
+ describe '#initialize' do
7
+ it 'starts in dormant mode' do
8
+ expect(state.mode).to eq(:dormant)
9
+ end
10
+
11
+ it 'starts with zero tick count' do
12
+ expect(state.tick_count).to eq(0)
13
+ end
14
+ end
15
+
16
+ describe '#record_signal' do
17
+ it 'updates last_signal_at' do
18
+ state.record_signal
19
+ expect(state.last_signal_at).not_to be_nil
20
+ end
21
+
22
+ it 'updates last_high_salience_at for high salience signals' do
23
+ state.record_signal(salience: 0.8)
24
+ expect(state.last_high_salience_at).not_to be_nil
25
+ end
26
+
27
+ it 'does not update last_high_salience_at for low salience' do
28
+ state.record_signal(salience: 0.3)
29
+ expect(state.last_high_salience_at).to be_nil
30
+ end
31
+ end
32
+
33
+ describe '#increment_tick' do
34
+ it 'increments the tick count' do
35
+ state.increment_tick
36
+ expect(state.tick_count).to eq(1)
37
+ end
38
+
39
+ it 'clears phase results' do
40
+ state.record_phase(:test, { result: true })
41
+ state.increment_tick
42
+ expect(state.phase_results).to be_empty
43
+ end
44
+ end
45
+
46
+ describe '#transition_to' do
47
+ it 'changes mode' do
48
+ state.transition_to(:sentinel)
49
+ expect(state.mode).to eq(:sentinel)
50
+ end
51
+
52
+ it 'tracks mode history' do
53
+ state.transition_to(:sentinel)
54
+ state.transition_to(:full_active)
55
+ expect(state.mode_history.size).to eq(3) # initial + 2 transitions
56
+ end
57
+
58
+ it 'does not add duplicate entries for same mode' do
59
+ state.transition_to(:dormant) # already dormant
60
+ expect(state.mode_history.size).to eq(1)
61
+ end
62
+ end
63
+
64
+ describe '#to_h' do
65
+ it 'returns state as hash' do
66
+ h = state.to_h
67
+ expect(h).to have_key(:mode)
68
+ expect(h).to have_key(:tick_count)
69
+ expect(h).to have_key(:phases_completed)
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/tick/client'
4
+
5
+ RSpec.describe Legion::Extensions::Tick::Runners::Orchestrator do
6
+ let(:client) { Legion::Extensions::Tick::Client.new }
7
+
8
+ describe '#execute_tick' do
9
+ before do
10
+ allow(client.send(:tick_state)).to receive(:seconds_since_signal).and_return(60.0)
11
+ end
12
+
13
+ it 'executes phases for current mode' do
14
+ result = client.execute_tick
15
+ expect(result[:mode]).to eq(:dormant)
16
+ expect(result[:phases_executed]).to include(:memory_consolidation)
17
+ end
18
+
19
+ it 'increments tick number' do
20
+ r1 = client.execute_tick
21
+ r2 = client.execute_tick
22
+ expect(r2[:tick_number]).to eq(r1[:tick_number] + 1)
23
+ end
24
+
25
+ it 'uses phase handlers when provided' do
26
+ handler_called = false
27
+ handlers = {
28
+ memory_consolidation: lambda { |**|
29
+ handler_called = true
30
+ { status: :ok }
31
+ }
32
+ }
33
+ client.execute_tick(phase_handlers: handlers)
34
+ expect(handler_called).to be true
35
+ end
36
+
37
+ it 'reports no_handler for unhandled phases' do
38
+ result = client.execute_tick
39
+ expect(result[:results][:memory_consolidation][:status]).to eq(:no_handler)
40
+ end
41
+
42
+ it 'promotes to sentinel on incoming signals' do
43
+ client.execute_tick(signals: [{ salience: 0.3 }])
44
+ result = client.execute_tick(signals: [{ salience: 0.3 }])
45
+ expect(result[:mode]).to eq(:sentinel)
46
+ end
47
+ end
48
+
49
+ describe '#evaluate_mode_transition' do
50
+ it 'promotes dormant to sentinel on any signal' do
51
+ result = client.evaluate_mode_transition(signals: [{ salience: 0.1 }])
52
+ expect(result[:transitioned]).to be true
53
+ expect(result[:new_mode]).to eq(:sentinel)
54
+ end
55
+
56
+ it 'promotes sentinel to full_active on high salience' do
57
+ client.set_mode(mode: :sentinel)
58
+ result = client.evaluate_mode_transition(signals: [{ salience: 0.9 }])
59
+ expect(result[:transitioned]).to be true
60
+ expect(result[:new_mode]).to eq(:full_active)
61
+ end
62
+
63
+ it 'promotes sentinel to full_active on human interaction' do
64
+ client.set_mode(mode: :sentinel)
65
+ result = client.evaluate_mode_transition(signals: [{ source_type: :human_direct, salience: 0.3 }])
66
+ expect(result[:transitioned]).to be true
67
+ expect(result[:new_mode]).to eq(:full_active)
68
+ end
69
+
70
+ it 'promotes to full_active on emergency' do
71
+ result = client.evaluate_mode_transition(emergency: :firmware_violation)
72
+ expect(result[:transitioned]).to be true
73
+ expect(result[:new_mode]).to eq(:full_active)
74
+ end
75
+
76
+ it 'does not transition without trigger when recently active' do
77
+ state = client.send(:tick_state)
78
+ allow(state).to receive(:seconds_since_signal).and_return(60.0)
79
+ result = client.evaluate_mode_transition
80
+ expect(result[:transitioned]).to be false
81
+ end
82
+
83
+ context 'dormant_active transitions' do
84
+ it 'transitions dormant -> dormant_active after DREAM_IDLE_THRESHOLD with no signals' do
85
+ state = client.send(:tick_state)
86
+ allow(state).to receive(:seconds_since_signal).and_return(1801.0)
87
+ result = client.evaluate_mode_transition
88
+ expect(result[:transitioned]).to be true
89
+ expect(result[:new_mode]).to eq(:dormant_active)
90
+ end
91
+
92
+ it 'transitions dormant -> sentinel when signals arrive (not dormant_active)' do
93
+ state = client.send(:tick_state)
94
+ allow(state).to receive(:seconds_since_signal).and_return(1801.0)
95
+ result = client.evaluate_mode_transition(signals: [{ salience: 0.3 }])
96
+ expect(result[:transitioned]).to be true
97
+ expect(result[:new_mode]).to eq(:sentinel)
98
+ end
99
+
100
+ it 'transitions dormant_active -> sentinel on high-salience signal' do
101
+ client.set_mode(mode: :dormant_active)
102
+ result = client.evaluate_mode_transition(signals: [{ salience: 0.9 }])
103
+ expect(result[:transitioned]).to be true
104
+ expect(result[:new_mode]).to eq(:sentinel)
105
+ end
106
+
107
+ it 'transitions dormant_active -> dormant when dream_complete: true with no signals' do
108
+ client.set_mode(mode: :dormant_active)
109
+ result = client.evaluate_mode_transition(dream_complete: true)
110
+ expect(result[:transitioned]).to be true
111
+ expect(result[:new_mode]).to eq(:dormant)
112
+ end
113
+
114
+ it 'stays dormant_active when no signals and dream not complete' do
115
+ client.set_mode(mode: :dormant_active)
116
+ result = client.evaluate_mode_transition
117
+ expect(result[:transitioned]).to be false
118
+ expect(result[:current_mode]).to eq(:dormant_active)
119
+ end
120
+ end
121
+
122
+ context 'sentinel -> dormant_active' do
123
+ it 'transitions sentinel -> dormant_active after SENTINEL_TO_DREAM_THRESHOLD with no signals' do
124
+ client.set_mode(mode: :sentinel)
125
+ state = client.send(:tick_state)
126
+ allow(state).to receive(:seconds_since_signal).and_return(601.0)
127
+ result = client.evaluate_mode_transition
128
+ expect(result[:transitioned]).to be true
129
+ expect(result[:new_mode]).to eq(:dormant_active)
130
+ end
131
+ end
132
+ end
133
+
134
+ describe '#set_mode' do
135
+ it 'sets valid mode' do
136
+ result = client.set_mode(mode: :full_active)
137
+ expect(result[:mode]).to eq(:full_active)
138
+ end
139
+
140
+ it 'rejects invalid mode' do
141
+ result = client.set_mode(mode: :invalid)
142
+ expect(result[:error]).to eq(:invalid_mode)
143
+ end
144
+ end
145
+
146
+ describe '#tick_status' do
147
+ it 'returns current state' do
148
+ status = client.tick_status
149
+ expect(status[:mode]).to eq(:dormant)
150
+ expect(status[:tick_count]).to eq(0)
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/setup'
4
+
5
+ module Legion
6
+ module Logging
7
+ def self.debug(_msg); end
8
+ def self.info(_msg); end
9
+ def self.warn(_msg); end
10
+ def self.error(_msg); end
11
+ end
12
+ end
13
+
14
+ require 'legion/extensions/tick'
15
+
16
+ RSpec.configure do |config|
17
+ config.example_status_persistence_file_path = '.rspec_status'
18
+ config.disable_monkey_patching!
19
+ config.expect_with(:rspec) { |c| c.syntax = :expect }
20
+ end
metadata ADDED
@@ -0,0 +1,76 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: lex-tick
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Esity
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: legion-gaia
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :development
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
26
+ description: Atomic cognitive processing cycle (11 phases, 3 modes) for brain-modeled
27
+ agentic AI
28
+ email:
29
+ - matthewdiverson@gmail.com
30
+ executables: []
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - Gemfile
35
+ - lex-tick.gemspec
36
+ - lib/legion/extensions/tick.rb
37
+ - lib/legion/extensions/tick/actors/tick.rb
38
+ - lib/legion/extensions/tick/client.rb
39
+ - lib/legion/extensions/tick/helpers/constants.rb
40
+ - lib/legion/extensions/tick/helpers/state.rb
41
+ - lib/legion/extensions/tick/runners/orchestrator.rb
42
+ - lib/legion/extensions/tick/version.rb
43
+ - spec/legion/extensions/tick/actors/tick_spec.rb
44
+ - spec/legion/extensions/tick/client_spec.rb
45
+ - spec/legion/extensions/tick/helpers/constants_spec.rb
46
+ - spec/legion/extensions/tick/helpers/state_spec.rb
47
+ - spec/legion/extensions/tick/runners/orchestrator_spec.rb
48
+ - spec/spec_helper.rb
49
+ homepage: https://github.com/LegionIO/lex-tick
50
+ licenses:
51
+ - MIT
52
+ metadata:
53
+ homepage_uri: https://github.com/LegionIO/lex-tick
54
+ source_code_uri: https://github.com/LegionIO/lex-tick
55
+ documentation_uri: https://github.com/LegionIO/lex-tick
56
+ changelog_uri: https://github.com/LegionIO/lex-tick
57
+ bug_tracker_uri: https://github.com/LegionIO/lex-tick/issues
58
+ rubygems_mfa_required: 'true'
59
+ rdoc_options: []
60
+ require_paths:
61
+ - lib
62
+ required_ruby_version: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - ">="
65
+ - !ruby/object:Gem::Version
66
+ version: '3.4'
67
+ required_rubygems_version: !ruby/object:Gem::Requirement
68
+ requirements:
69
+ - - ">="
70
+ - !ruby/object:Gem::Version
71
+ version: '0'
72
+ requirements: []
73
+ rubygems_version: 3.6.9
74
+ specification_version: 4
75
+ summary: LEX Tick
76
+ test_files: []