lex-cognitive-tide 0.1.1

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.
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Actors
6
+ class Every # rubocop:disable Lint/EmptyClass
7
+ end
8
+ end
9
+ end
10
+ end
11
+
12
+ $LOADED_FEATURES << 'legion/extensions/actors/every'
13
+
14
+ require_relative '../../../../../lib/legion/extensions/cognitive_tide/actors/tide_cycle'
15
+
16
+ RSpec.describe Legion::Extensions::CognitiveTide::Actor::TideCycle do
17
+ subject(:actor) { described_class.new }
18
+
19
+ describe '#runner_class' do
20
+ it { expect(actor.runner_class).to eq Legion::Extensions::CognitiveTide::Runners::CognitiveTide }
21
+ end
22
+
23
+ describe '#runner_function' do
24
+ it { expect(actor.runner_function).to eq 'tide_maintenance' }
25
+ end
26
+
27
+ describe '#time' do
28
+ it { expect(actor.time).to eq 60 }
29
+ end
30
+
31
+ describe '#run_now?' do
32
+ it { expect(actor.run_now?).to be false }
33
+ end
34
+
35
+ describe '#use_runner?' do
36
+ it { expect(actor.use_runner?).to be false }
37
+ end
38
+
39
+ describe '#check_subtask?' do
40
+ it { expect(actor.check_subtask?).to be false }
41
+ end
42
+
43
+ describe '#generate_task?' do
44
+ it { expect(actor.generate_task?).to be false }
45
+ end
46
+ end
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::CognitiveTide::Client do
4
+ let(:client) { described_class.new }
5
+
6
+ describe '#initialize' do
7
+ it 'creates a new client' do
8
+ expect(client).to be_a(described_class)
9
+ end
10
+
11
+ it 'responds to runner methods' do
12
+ expect(client).to respond_to(:add_oscillator)
13
+ expect(client).to respond_to(:check_tide)
14
+ expect(client).to respond_to(:deposit_idea)
15
+ expect(client).to respond_to(:harvest)
16
+ expect(client).to respond_to(:tide_forecast)
17
+ expect(client).to respond_to(:tide_status)
18
+ end
19
+ end
20
+
21
+ describe 'full lifecycle' do
22
+ it 'adds oscillators, deposits ideas, and harvests on rising tide' do
23
+ # Add two oscillators
24
+ r1 = client.add_oscillator(oscillator_type: :primary, period: 86_400, amplitude: 1.0)
25
+ r2 = client.add_oscillator(oscillator_type: :secondary, period: 43_200, amplitude: 0.5)
26
+ expect(r1[:success]).to be(true)
27
+ expect(r2[:success]).to be(true)
28
+
29
+ # Deposit ideas
30
+ d1 = client.deposit_idea(domain: 'architecture', idea: 'refactor auth module')
31
+ d2 = client.deposit_idea(domain: 'architecture', idea: 'simplify routing layer')
32
+ d3 = client.deposit_idea(domain: 'testing', idea: 'add integration specs')
33
+ expect(d1[:success]).to be(true)
34
+ expect(d2[:success]).to be(true)
35
+ expect(d3[:success]).to be(true)
36
+
37
+ # Check tide
38
+ tide = client.check_tide
39
+ expect(tide[:level]).to be_between(0.0, 1.0)
40
+ expect(tide[:phase]).to be_a(Symbol)
41
+
42
+ # Harvest (result depends on rising? state, but should not error)
43
+ harvest = client.harvest
44
+ expect(harvest[:success]).to be(true)
45
+ expect(harvest[:harvested]).to be_a(Hash)
46
+
47
+ # Status
48
+ status = client.tide_status
49
+ expect(status[:oscillator_count]).to eq(2)
50
+ expect(status[:success]).to be(true)
51
+ end
52
+
53
+ it 'forecasts the tide for a given duration' do
54
+ client.add_oscillator(oscillator_type: :lunar, period: 2_551_443, amplitude: 0.8)
55
+ result = client.tide_forecast(duration: 86_400)
56
+ expect(result[:success]).to be(true)
57
+ expect(result[:forecast]).to be_an(Array)
58
+ expect(result[:forecast]).not_to be_empty
59
+ end
60
+
61
+ it 'tidal pools accumulate ideas independently per domain' do
62
+ client.add_oscillator(oscillator_type: :primary, period: 86_400)
63
+ %w[alpha beta gamma].each do |domain|
64
+ 3.times { |i| client.deposit_idea(domain: domain, idea: "idea #{i} in #{domain}") }
65
+ end
66
+ status = client.tide_status
67
+ expect(status[:pool_count]).to eq(3)
68
+ end
69
+
70
+ it 'returns the same engine state across calls' do
71
+ client.add_oscillator(oscillator_type: :primary, period: 86_400)
72
+ status_a = client.tide_status
73
+ status_b = client.tide_status
74
+ expect(status_a[:oscillator_count]).to eq(status_b[:oscillator_count])
75
+ end
76
+ end
77
+
78
+ describe 'error handling' do
79
+ it 'returns success: false for invalid oscillator_type without raising' do
80
+ result = client.add_oscillator(oscillator_type: :bogus, period: 3600)
81
+ expect(result[:success]).to be(false)
82
+ expect(result[:error]).to be_a(String)
83
+ end
84
+
85
+ it 'returns success: false for non-positive period without raising' do
86
+ result = client.add_oscillator(oscillator_type: :primary, period: -1)
87
+ expect(result[:success]).to be(false)
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::CognitiveTide::Helpers::Constants do
4
+ describe 'TIDE_PHASES' do
5
+ it 'contains the four expected phases' do
6
+ expect(described_class::TIDE_PHASES).to eq(%i[rising high_tide falling low_tide])
7
+ end
8
+
9
+ it 'is frozen' do
10
+ expect(described_class::TIDE_PHASES).to be_frozen
11
+ end
12
+ end
13
+
14
+ describe 'OSCILLATOR_TYPES' do
15
+ it 'contains primary, secondary, and lunar' do
16
+ expect(described_class::OSCILLATOR_TYPES).to eq(%i[primary secondary lunar])
17
+ end
18
+
19
+ it 'is frozen' do
20
+ expect(described_class::OSCILLATOR_TYPES).to be_frozen
21
+ end
22
+ end
23
+
24
+ describe 'MAX_POOLS' do
25
+ it 'is a positive integer' do
26
+ expect(described_class::MAX_POOLS).to be_a(Integer)
27
+ expect(described_class::MAX_POOLS).to be > 0
28
+ end
29
+
30
+ it 'equals 50' do
31
+ expect(described_class::MAX_POOLS).to eq(50)
32
+ end
33
+ end
34
+
35
+ describe 'POOL_EVAPORATION_RATE' do
36
+ it 'is a float between 0 and 1' do
37
+ expect(described_class::POOL_EVAPORATION_RATE).to be_a(Float)
38
+ expect(described_class::POOL_EVAPORATION_RATE).to be >= 0.0
39
+ expect(described_class::POOL_EVAPORATION_RATE).to be <= 1.0
40
+ end
41
+
42
+ it 'equals 0.01' do
43
+ expect(described_class::POOL_EVAPORATION_RATE).to eq(0.01)
44
+ end
45
+ end
46
+
47
+ describe 'TIDE_LABELS' do
48
+ it 'is frozen' do
49
+ expect(described_class::TIDE_LABELS).to be_frozen
50
+ end
51
+
52
+ it 'contains five label entries' do
53
+ expect(described_class::TIDE_LABELS.size).to eq(5)
54
+ end
55
+
56
+ it 'each entry has a range and a label' do
57
+ described_class::TIDE_LABELS.each do |entry|
58
+ expect(entry[:range]).to be_a(Range)
59
+ expect(entry[:label]).to be_a(String)
60
+ end
61
+ end
62
+
63
+ it 'covers the full [0, 1] range collectively' do
64
+ levels = [0.0, 0.1, 0.25, 0.45, 0.65, 0.85, 1.0]
65
+ levels.each do |level|
66
+ matched = described_class::TIDE_LABELS.any? { |tl| tl[:range].cover?(level) }
67
+ expect(matched).to be(true), "No label for level #{level}"
68
+ end
69
+ end
70
+
71
+ it 'maps 1.0 to peak' do
72
+ entry = described_class::TIDE_LABELS.find { |tl| tl[:range].cover?(1.0) }
73
+ expect(entry[:label]).to eq('peak')
74
+ end
75
+
76
+ it 'maps 0.0 to ebb' do
77
+ entry = described_class::TIDE_LABELS.find { |tl| tl[:range].cover?(0.0) }
78
+ expect(entry[:label]).to eq('ebb')
79
+ end
80
+
81
+ it 'maps 0.75 to high' do
82
+ entry = described_class::TIDE_LABELS.find { |tl| tl[:range].cover?(0.75) }
83
+ expect(entry[:label]).to eq('high')
84
+ end
85
+ end
86
+
87
+ describe 'HARVEST_RISING_THRESHOLD' do
88
+ it 'is a float between 0 and 1' do
89
+ expect(described_class::HARVEST_RISING_THRESHOLD).to be_a(Float)
90
+ expect(described_class::HARVEST_RISING_THRESHOLD).to be_between(0.0, 1.0)
91
+ end
92
+ end
93
+
94
+ describe 'FORECAST_RESOLUTION' do
95
+ it 'is a positive integer (seconds)' do
96
+ expect(described_class::FORECAST_RESOLUTION).to be_a(Integer)
97
+ expect(described_class::FORECAST_RESOLUTION).to be > 0
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::CognitiveTide::Helpers::Oscillator do
4
+ let(:primary) { described_class.new(oscillator_type: :primary, period: 86_400, amplitude: 1.0, phase_offset: 0.0) }
5
+ let(:secondary) { described_class.new(oscillator_type: :secondary, period: 43_200, amplitude: 0.5, phase_offset: 1.5) }
6
+
7
+ describe '#initialize' do
8
+ it 'assigns oscillator_type' do
9
+ expect(primary.oscillator_type).to eq(:primary)
10
+ end
11
+
12
+ it 'assigns period as float' do
13
+ expect(primary.period).to eq(86_400.0)
14
+ end
15
+
16
+ it 'assigns amplitude clamped to [0, 1]' do
17
+ osc = described_class.new(oscillator_type: :lunar, period: 2_551_443, amplitude: 1.5)
18
+ expect(osc.amplitude).to eq(1.0)
19
+ end
20
+
21
+ it 'clamps amplitude at 0' do
22
+ osc = described_class.new(oscillator_type: :primary, period: 3600, amplitude: -0.5)
23
+ expect(osc.amplitude).to eq(0.0)
24
+ end
25
+
26
+ it 'assigns phase_offset as float' do
27
+ expect(secondary.phase_offset).to eq(1.5)
28
+ end
29
+
30
+ it 'generates a unique uuid id' do
31
+ o1 = described_class.new(oscillator_type: :primary, period: 3600)
32
+ o2 = described_class.new(oscillator_type: :primary, period: 3600)
33
+ expect(o1.id).not_to eq(o2.id)
34
+ expect(o1.id).to match(/\A[0-9a-f-]{36}\z/)
35
+ end
36
+
37
+ it 'raises ArgumentError for unknown oscillator_type' do
38
+ expect { described_class.new(oscillator_type: :unknown, period: 3600) }
39
+ .to raise_error(ArgumentError, /unknown oscillator_type/)
40
+ end
41
+
42
+ it 'raises ArgumentError when period is not positive' do
43
+ expect { described_class.new(oscillator_type: :primary, period: 0) }
44
+ .to raise_error(ArgumentError, /period must be positive/)
45
+ end
46
+
47
+ it 'raises ArgumentError for negative period' do
48
+ expect { described_class.new(oscillator_type: :primary, period: -100) }
49
+ .to raise_error(ArgumentError, /period must be positive/)
50
+ end
51
+ end
52
+
53
+ describe '#value_at' do
54
+ it 'returns a value between 0 and amplitude' do
55
+ val = primary.value_at(Time.now.utc)
56
+ expect(val).to be >= 0.0
57
+ expect(val).to be <= primary.amplitude
58
+ end
59
+
60
+ it 'returns 0.0 when amplitude is 0' do
61
+ osc = described_class.new(oscillator_type: :primary, period: 3600, amplitude: 0.0)
62
+ expect(osc.value_at(Time.now.utc)).to eq(0.0)
63
+ end
64
+
65
+ it 'varies over time within a period' do
66
+ t = Time.now.utc
67
+ values = (0...10).map { |i| primary.value_at(t + (i * 8640)) }
68
+ expect(values.uniq.size).to be > 1
69
+ end
70
+
71
+ it 'is deterministic for the same time' do
72
+ t = Time.now.utc
73
+ expect(primary.value_at(t)).to eq(primary.value_at(t))
74
+ end
75
+
76
+ it 'returns rounded value (10 decimal places)' do
77
+ val = primary.value_at(Time.now.utc)
78
+ expect(val).to eq(val.round(10))
79
+ end
80
+
81
+ it 'respects phase_offset — same period different offset yields different values' do
82
+ # Use t = period/4 so that sin(PI/2 + 0) = 1.0 and sin(PI/2 + PI/2) = 0.0
83
+ period = 3600
84
+ t = Time.at(period / 4.0).utc
85
+ o1 = described_class.new(oscillator_type: :primary, period: period, amplitude: 1.0, phase_offset: 0.0)
86
+ o2 = described_class.new(oscillator_type: :primary, period: period, amplitude: 1.0, phase_offset: Math::PI / 2.0)
87
+ expect(o1.value_at(t)).not_to be_within(0.05).of(o2.value_at(t))
88
+ end
89
+ end
90
+
91
+ describe '#current_value' do
92
+ it 'returns a numeric in [0, amplitude]' do
93
+ val = primary.current_value
94
+ expect(val).to be_a(Float)
95
+ expect(val).to be >= 0.0
96
+ expect(val).to be <= 1.0
97
+ end
98
+ end
99
+
100
+ describe '#tick!' do
101
+ it 'returns a numeric value' do
102
+ expect(primary.tick!).to be_a(Float)
103
+ end
104
+
105
+ it 'updates last_ticked_at' do
106
+ before = Time.now.utc
107
+ primary.tick!
108
+ expect(primary.to_h[:last_ticked_at]).to be >= before
109
+ end
110
+ end
111
+
112
+ describe '#in_phase_with?' do
113
+ it 'returns true when two identical oscillators are compared' do
114
+ o1 = described_class.new(oscillator_type: :primary, period: 86_400, amplitude: 1.0, phase_offset: 0.0)
115
+ o2 = described_class.new(oscillator_type: :primary, period: 86_400, amplitude: 1.0, phase_offset: 0.0)
116
+ expect(o1.in_phase_with?(o2)).to be(true)
117
+ end
118
+
119
+ it 'returns false when oscillators are 180 degrees out of phase' do
120
+ # At t = period/4: sin(PI/2 + 0) = 1.0 -> value = 1.0; sin(PI/2 + PI) = -1.0 -> value = 0.0
121
+ # Difference = 1.0, tolerance = 0.15 * (1+1) = 0.30 — NOT in phase
122
+ period = 3600
123
+ t = Time.at(period / 4.0).utc
124
+ allow(Time).to receive(:now).and_return(t)
125
+ o1 = described_class.new(oscillator_type: :primary, period: period, amplitude: 1.0, phase_offset: 0.0)
126
+ o2 = described_class.new(oscillator_type: :secondary, period: period, amplitude: 1.0, phase_offset: Math::PI)
127
+ expect(o1.in_phase_with?(o2)).to be(false)
128
+ end
129
+
130
+ it 'returns false when combined amplitude is zero' do
131
+ o1 = described_class.new(oscillator_type: :primary, period: 3600, amplitude: 0.0)
132
+ o2 = described_class.new(oscillator_type: :secondary, period: 3600, amplitude: 0.0)
133
+ expect(o1.in_phase_with?(o2)).to be(false)
134
+ end
135
+ end
136
+
137
+ describe '#to_h' do
138
+ it 'returns a hash with expected keys' do
139
+ h = primary.to_h
140
+ expect(h.keys).to include(:id, :oscillator_type, :period, :amplitude, :phase_offset,
141
+ :current_value, :last_ticked_at)
142
+ end
143
+
144
+ it 'reflects the correct oscillator_type' do
145
+ expect(primary.to_h[:oscillator_type]).to eq(:primary)
146
+ end
147
+
148
+ it 'last_ticked_at is nil before first tick' do
149
+ osc = described_class.new(oscillator_type: :primary, period: 3600)
150
+ expect(osc.to_h[:last_ticked_at]).to be_nil
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::CognitiveTide::Helpers::TidalPool do
4
+ let(:pool) { described_class.new(domain: 'architecture', capacity: 5) }
5
+
6
+ describe '#initialize' do
7
+ it 'assigns domain as string' do
8
+ expect(pool.domain).to eq('architecture')
9
+ end
10
+
11
+ it 'accepts symbol domain and converts to string' do
12
+ p = described_class.new(domain: :philosophy)
13
+ expect(p.domain).to eq('philosophy')
14
+ end
15
+
16
+ it 'assigns capacity' do
17
+ expect(pool.capacity).to eq(5)
18
+ end
19
+
20
+ it 'starts empty' do
21
+ expect(pool.size).to eq(0)
22
+ end
23
+
24
+ it 'starts with zero evaporation_count' do
25
+ expect(pool.evaporation_count).to eq(0)
26
+ end
27
+
28
+ it 'generates a uuid id' do
29
+ expect(pool.id).to match(/\A[0-9a-f-]{36}\z/)
30
+ end
31
+
32
+ it 'raises ArgumentError for non-positive capacity' do
33
+ expect { described_class.new(domain: 'test', capacity: 0) }
34
+ .to raise_error(ArgumentError, /capacity must be positive/)
35
+ end
36
+
37
+ it 'raises ArgumentError for negative capacity' do
38
+ expect { described_class.new(domain: 'test', capacity: -1) }
39
+ .to raise_error(ArgumentError, /capacity must be positive/)
40
+ end
41
+ end
42
+
43
+ describe '#deposit' do
44
+ it 'deposits an item and returns true' do
45
+ result = pool.deposit('refactor auth layer')
46
+ expect(result).to be(true)
47
+ expect(pool.size).to eq(1)
48
+ end
49
+
50
+ it 'stores items with metadata' do
51
+ pool.deposit('idea one')
52
+ item = pool.items.first
53
+ expect(item[:content]).to eq('idea one')
54
+ expect(item[:deposited_at]).to be_a(Time)
55
+ expect(item[:id]).to match(/\A[0-9a-f-]{36}\z/)
56
+ end
57
+
58
+ it 'returns false when pool is full' do
59
+ 5.times { |i| pool.deposit("idea #{i}") }
60
+ expect(pool.deposit('overflow')).to be(false)
61
+ end
62
+
63
+ it 'does not add item when full' do
64
+ 5.times { |i| pool.deposit("idea #{i}") }
65
+ pool.deposit('overflow')
66
+ expect(pool.size).to eq(5)
67
+ end
68
+
69
+ it 'accepts any object as item' do
70
+ pool.deposit({ key: 'value' })
71
+ pool.deposit(42)
72
+ pool.deposit(:symbol)
73
+ expect(pool.size).to eq(3)
74
+ end
75
+ end
76
+
77
+ describe '#harvest!' do
78
+ it 'returns all items' do
79
+ pool.deposit('alpha')
80
+ pool.deposit('beta')
81
+ harvested = pool.harvest!
82
+ expect(harvested.size).to eq(2)
83
+ expect(harvested.map { |i| i[:content] }).to contain_exactly('alpha', 'beta')
84
+ end
85
+
86
+ it 'clears the pool after harvest' do
87
+ pool.deposit('alpha')
88
+ pool.harvest!
89
+ expect(pool.size).to eq(0)
90
+ end
91
+
92
+ it 'returns empty array when pool is empty' do
93
+ expect(pool.harvest!).to eq([])
94
+ end
95
+ end
96
+
97
+ describe '#evaporate!' do
98
+ let(:large_pool) { described_class.new(domain: 'test', capacity: 20) }
99
+
100
+ before { 10.times { |i| large_pool.deposit("item #{i}") } }
101
+
102
+ it 'removes a proportion of items (oldest first)' do
103
+ large_pool.evaporate!(0.2)
104
+ # ceil(10 * 0.2) = 2 removed
105
+ expect(large_pool.size).to eq(8)
106
+ end
107
+
108
+ it 'increments evaporation_count' do
109
+ large_pool.evaporate!(0.3)
110
+ expect(large_pool.evaporation_count).to be > 0
111
+ end
112
+
113
+ it 'returns the number of items removed' do
114
+ removed = large_pool.evaporate!(0.1)
115
+ expect(removed).to eq(1)
116
+ end
117
+
118
+ it 'clamps rate at 1.0 and removes all items' do
119
+ large_pool.evaporate!(2.0)
120
+ expect(large_pool.size).to eq(0)
121
+ end
122
+
123
+ it 'clamps rate at 0.0 and removes nothing' do
124
+ large_pool.evaporate!(-0.5)
125
+ expect(large_pool.size).to eq(10)
126
+ end
127
+
128
+ it 'uses POOL_EVAPORATION_RATE when no argument given' do
129
+ # 0.01 rate on 10 items: ceil(0.1) = 1 removed
130
+ removed = large_pool.evaporate!
131
+ expect(removed).to eq(1)
132
+ expect(large_pool.size).to eq(9)
133
+ end
134
+ end
135
+
136
+ describe '#full?' do
137
+ it 'returns false when below capacity' do
138
+ expect(pool.full?).to be(false)
139
+ end
140
+
141
+ it 'returns true when at capacity' do
142
+ 5.times { |i| pool.deposit("item #{i}") }
143
+ expect(pool.full?).to be(true)
144
+ end
145
+ end
146
+
147
+ describe '#depth' do
148
+ it 'returns 0.0 when empty' do
149
+ expect(pool.depth).to eq(0.0)
150
+ end
151
+
152
+ it 'returns 1.0 when full' do
153
+ 5.times { |i| pool.deposit("item #{i}") }
154
+ expect(pool.depth).to eq(1.0)
155
+ end
156
+
157
+ it 'returns fractional depth' do
158
+ pool.deposit('one')
159
+ pool.deposit('two')
160
+ expect(pool.depth).to be_within(0.001).of(0.4)
161
+ end
162
+
163
+ it 'rounds to 10 decimal places' do
164
+ pool.deposit('one')
165
+ expect(pool.depth).to eq(pool.depth.round(10))
166
+ end
167
+ end
168
+
169
+ describe '#items' do
170
+ it 'returns a copy of items (not the internal array)' do
171
+ pool.deposit('item')
172
+ items = pool.items
173
+ items.clear
174
+ expect(pool.size).to eq(1)
175
+ end
176
+ end
177
+
178
+ describe '#to_h' do
179
+ it 'returns expected keys' do
180
+ h = pool.to_h
181
+ expect(h.keys).to include(:id, :domain, :capacity, :size, :depth, :evaporation_count, :created_at)
182
+ end
183
+
184
+ it 'size reflects current item count' do
185
+ pool.deposit('x')
186
+ expect(pool.to_h[:size]).to eq(1)
187
+ end
188
+
189
+ it 'created_at is a Time' do
190
+ expect(pool.to_h[:created_at]).to be_a(Time)
191
+ end
192
+ end
193
+ end