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.
- checksums.yaml +7 -0
- data/Gemfile +13 -0
- data/LICENSE +21 -0
- data/README.md +41 -0
- data/lex-cognitive-tide.gemspec +30 -0
- data/lib/legion/extensions/cognitive_tide/actors/tide_cycle.rb +41 -0
- data/lib/legion/extensions/cognitive_tide/client.rb +25 -0
- data/lib/legion/extensions/cognitive_tide/helpers/constants.rb +36 -0
- data/lib/legion/extensions/cognitive_tide/helpers/oscillator.rb +69 -0
- data/lib/legion/extensions/cognitive_tide/helpers/tidal_pool.rb +83 -0
- data/lib/legion/extensions/cognitive_tide/helpers/tide_engine.rb +167 -0
- data/lib/legion/extensions/cognitive_tide/runners/cognitive_tide.rb +118 -0
- data/lib/legion/extensions/cognitive_tide/version.rb +9 -0
- data/lib/legion/extensions/cognitive_tide.rb +17 -0
- data/spec/legion/extensions/cognitive_tide/actors/tide_cycle_spec.rb +46 -0
- data/spec/legion/extensions/cognitive_tide/client_spec.rb +90 -0
- data/spec/legion/extensions/cognitive_tide/helpers/constants_spec.rb +100 -0
- data/spec/legion/extensions/cognitive_tide/helpers/oscillator_spec.rb +153 -0
- data/spec/legion/extensions/cognitive_tide/helpers/tidal_pool_spec.rb +193 -0
- data/spec/legion/extensions/cognitive_tide/helpers/tide_engine_spec.rb +325 -0
- data/spec/legion/extensions/cognitive_tide/runners/cognitive_tide_spec.rb +249 -0
- data/spec/spec_helper.rb +27 -0
- metadata +82 -0
|
@@ -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
|