lex-situation-model 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.
@@ -0,0 +1,203 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::SituationModel::Helpers::SituationEngine do
4
+ let(:engine) { described_class.new }
5
+
6
+ def add_coherent_events(model_id, count)
7
+ count.times do |i|
8
+ engine.add_event_to_model(
9
+ model_id: model_id,
10
+ content: "event #{i}",
11
+ dimension_values: { space: 0.8, time: 0.8, causation: 0.8, intentionality: 0.8, protagonist: 0.8 }
12
+ )
13
+ end
14
+ end
15
+
16
+ describe '#create_model' do
17
+ it 'returns a SituationModel' do
18
+ model = engine.create_model(label: 'test')
19
+ expect(model).to be_a(Legion::Extensions::SituationModel::Helpers::SituationModel)
20
+ end
21
+
22
+ it 'assigns the given label' do
23
+ model = engine.create_model(label: 'narrative_x')
24
+ expect(model.label).to eq('narrative_x')
25
+ end
26
+
27
+ it 'stores the model by id' do
28
+ model = engine.create_model(label: 'stored')
29
+ expect(engine.model_coherence(model_id: model.id)).not_to be_nil
30
+ end
31
+ end
32
+
33
+ describe '#add_event_to_model' do
34
+ it 'returns the created event' do
35
+ model = engine.create_model(label: 'e')
36
+ event = engine.add_event_to_model(model_id: model.id, content: 'hi')
37
+ expect(event).to be_a(Legion::Extensions::SituationModel::Helpers::SituationEvent)
38
+ end
39
+
40
+ it 'returns nil for unknown model_id' do
41
+ result = engine.add_event_to_model(model_id: 'nonexistent', content: 'x')
42
+ expect(result).to be_nil
43
+ end
44
+
45
+ it 'grows the model event count' do
46
+ model = engine.create_model(label: 'growing')
47
+ engine.add_event_to_model(model_id: model.id, content: 'first')
48
+ engine.add_event_to_model(model_id: model.id, content: 'second')
49
+ expect(model.events.size).to eq(2)
50
+ end
51
+
52
+ it 'passes dimension values to the event' do
53
+ model = engine.create_model(label: 'dims')
54
+ engine.add_event_to_model(model_id: model.id, content: 'test',
55
+ dimension_values: { space: 0.9, time: 0.1, causation: 0.5,
56
+ intentionality: 0.5, protagonist: 0.5 })
57
+ expect(model.events.last.dimension_values[:space]).to be_within(0.001).of(0.9)
58
+ end
59
+ end
60
+
61
+ describe '#model_coherence' do
62
+ it 'returns nil for unknown model' do
63
+ expect(engine.model_coherence(model_id: 'bad')).to be_nil
64
+ end
65
+
66
+ it 'returns 1.0 for empty model' do
67
+ model = engine.create_model(label: 'empty')
68
+ expect(engine.model_coherence(model_id: model.id)).to eq(1.0)
69
+ end
70
+
71
+ it 'returns coherence value for model with events' do
72
+ model = engine.create_model(label: 'coh')
73
+ add_coherent_events(model.id, 3)
74
+ expect(engine.model_coherence(model_id: model.id)).to be > 0.8
75
+ end
76
+ end
77
+
78
+ describe '#find_boundaries' do
79
+ it 'returns nil for unknown model' do
80
+ expect(engine.find_boundaries(model_id: 'bad')).to be_nil
81
+ end
82
+
83
+ it 'returns empty array for highly coherent model' do
84
+ model = engine.create_model(label: 'coherent')
85
+ add_coherent_events(model.id, 3)
86
+ expect(engine.find_boundaries(model_id: model.id, threshold: 0.5)).to be_empty
87
+ end
88
+
89
+ it 'detects discontinuities' do
90
+ model = engine.create_model(label: 'jump')
91
+ engine.add_event_to_model(model_id: model.id, content: 'high',
92
+ dimension_values: { space: 0.9, time: 0.9, causation: 0.9, intentionality: 0.9, protagonist: 0.9 })
93
+ engine.add_event_to_model(model_id: model.id, content: 'low',
94
+ dimension_values: { space: 0.0, time: 0.0, causation: 0.0, intentionality: 0.0, protagonist: 0.0 })
95
+ boundaries = engine.find_boundaries(model_id: model.id, threshold: 0.3)
96
+ expect(boundaries).to include(1)
97
+ end
98
+ end
99
+
100
+ describe '#dimension_trajectory' do
101
+ it 'returns nil for unknown model' do
102
+ expect(engine.dimension_trajectory(model_id: 'bad', dimension: :space)).to be_nil
103
+ end
104
+
105
+ it 'returns array of values for known dimension' do
106
+ model = engine.create_model(label: 'traj')
107
+ engine.add_event_to_model(model_id: model.id, content: 'a',
108
+ dimension_values: { space: 0.3, time: 0.5, causation: 0.5, intentionality: 0.5, protagonist: 0.5 })
109
+ engine.add_event_to_model(model_id: model.id, content: 'b',
110
+ dimension_values: { space: 0.7, time: 0.5, causation: 0.5, intentionality: 0.5, protagonist: 0.5 })
111
+ traj = engine.dimension_trajectory(model_id: model.id, dimension: :space)
112
+ expect(traj).to eq([0.3, 0.7])
113
+ end
114
+ end
115
+
116
+ describe '#most_coherent' do
117
+ it 'returns models sorted by coherence desc' do
118
+ m1 = engine.create_model(label: 'low')
119
+ engine.add_event_to_model(model_id: m1.id, content: 'a',
120
+ dimension_values: { space: 0.9, time: 0.9, causation: 0.9, intentionality: 0.9, protagonist: 0.9 })
121
+ engine.add_event_to_model(model_id: m1.id, content: 'b',
122
+ dimension_values: { space: 0.0, time: 0.0, causation: 0.0, intentionality: 0.0, protagonist: 0.0 })
123
+
124
+ m2 = engine.create_model(label: 'high')
125
+ add_coherent_events(m2.id, 3)
126
+
127
+ results = engine.most_coherent(limit: 2)
128
+ expect(results.first.id).to eq(m2.id)
129
+ end
130
+
131
+ it 'respects limit' do
132
+ 3.times { |i| engine.create_model(label: "m#{i}") }
133
+ expect(engine.most_coherent(limit: 2).size).to eq(2)
134
+ end
135
+ end
136
+
137
+ describe '#models_by_label' do
138
+ it 'returns models matching label' do
139
+ engine.create_model(label: 'alpha')
140
+ engine.create_model(label: 'alpha')
141
+ engine.create_model(label: 'beta')
142
+ result = engine.models_by_label(label: 'alpha')
143
+ expect(result.size).to eq(2)
144
+ expect(result.all? { |m| m.label == 'alpha' }).to be(true)
145
+ end
146
+
147
+ it 'returns empty array for unknown label' do
148
+ expect(engine.models_by_label(label: 'nope')).to be_empty
149
+ end
150
+ end
151
+
152
+ describe '#decay_all' do
153
+ it 'decays all models' do
154
+ m1 = engine.create_model(label: 'a')
155
+ m2 = engine.create_model(label: 'b')
156
+ engine.add_event_to_model(model_id: m1.id, content: 'e1',
157
+ dimension_values: { space: 0.8, time: 0.8, causation: 0.8, intentionality: 0.8, protagonist: 0.8 })
158
+ engine.add_event_to_model(model_id: m2.id, content: 'e2',
159
+ dimension_values: { space: 0.6, time: 0.6, causation: 0.6, intentionality: 0.6, protagonist: 0.6 })
160
+
161
+ before_m1 = m1.current_state[:space]
162
+ before_m2 = m2.current_state[:space]
163
+ engine.decay_all
164
+ expect(m1.current_state[:space]).to be < before_m1
165
+ expect(m2.current_state[:space]).to be < before_m2
166
+ end
167
+ end
168
+
169
+ describe '#prune_collapsed' do
170
+ it 'removes models with coherence <= 0.1' do
171
+ model = engine.create_model(label: 'collapse')
172
+ # Add maximally discontinuous events repeatedly to drive coherence very low
173
+ 5.times do |i|
174
+ engine.add_event_to_model(
175
+ model_id: model.id,
176
+ content: "e#{i}",
177
+ dimension_values: if i.even?
178
+ { space: 0.0, time: 0.0, causation: 0.0, intentionality: 0.0, protagonist: 0.0 }
179
+ else
180
+ { space: 1.0, time: 1.0, causation: 1.0, intentionality: 1.0, protagonist: 1.0 }
181
+ end
182
+ )
183
+ end
184
+
185
+ coherent_model = engine.create_model(label: 'coherent')
186
+ add_coherent_events(coherent_model.id, 3)
187
+
188
+ engine.prune_collapsed
189
+ # coherent model should survive
190
+ expect(engine.model_coherence(model_id: coherent_model.id)).not_to be_nil
191
+ end
192
+ end
193
+
194
+ describe '#to_h' do
195
+ it 'includes model_count and models' do
196
+ engine.create_model(label: 'x')
197
+ h = engine.to_h
198
+ expect(h[:model_count]).to eq(1)
199
+ expect(h[:models]).to be_an(Array)
200
+ expect(h[:models].size).to eq(1)
201
+ end
202
+ end
203
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::SituationModel::Helpers::SituationEvent do
4
+ let(:default_dims) { { space: 0.5, time: 0.5, causation: 0.5, intentionality: 0.5, protagonist: 0.5 } }
5
+ let(:event_a) { described_class.new(content: 'hero walks into room', dimension_values: default_dims) }
6
+ let(:event_b) { described_class.new(content: 'hero walks forward', dimension_values: default_dims) }
7
+
8
+ describe '#initialize' do
9
+ it 'stores content' do
10
+ expect(event_a.content).to eq('hero walks into room')
11
+ end
12
+
13
+ it 'stores all 5 dimension values' do
14
+ expect(event_a.dimension_values.keys).to contain_exactly(*Legion::Extensions::SituationModel::Helpers::Constants::DIMENSIONS)
15
+ end
16
+
17
+ it 'defaults missing dimensions to 0.5' do
18
+ event = described_class.new(content: 'test')
19
+ expect(event.dimension_values[:space]).to eq(0.5)
20
+ end
21
+
22
+ it 'clamps dimension values to [0, 1]' do
23
+ event = described_class.new(content: 'test', dimension_values: { space: 1.5, time: -0.3 })
24
+ expect(event.dimension_values[:space]).to eq(1.0)
25
+ expect(event.dimension_values[:time]).to eq(0.0)
26
+ end
27
+
28
+ it 'records created_at timestamp' do
29
+ expect(event_a.created_at).to be_a(Time)
30
+ end
31
+ end
32
+
33
+ describe '#continuity_with' do
34
+ it 'returns 1.0 for identical events' do
35
+ expect(event_a.continuity_with(event_b)).to be_within(0.001).of(1.0)
36
+ end
37
+
38
+ it 'returns 0.0 when all dimensions are maximally different' do
39
+ e1 = described_class.new(content: 'a', dimension_values: { space: 0.0, time: 0.0, causation: 0.0, intentionality: 0.0, protagonist: 0.0 })
40
+ e2 = described_class.new(content: 'b', dimension_values: { space: 1.0, time: 1.0, causation: 1.0, intentionality: 1.0, protagonist: 1.0 })
41
+ expect(e1.continuity_with(e2)).to be_within(0.001).of(0.0)
42
+ end
43
+
44
+ it 'returns intermediate value for partial differences' do
45
+ e1 = described_class.new(content: 'a', dimension_values: { space: 0.0, time: 0.5, causation: 0.5, intentionality: 0.5, protagonist: 0.5 })
46
+ e2 = described_class.new(content: 'b', dimension_values: { space: 1.0, time: 0.5, causation: 0.5, intentionality: 0.5, protagonist: 0.5 })
47
+ # Only space differs by 1.0, avg diff = 1.0/5 = 0.2, continuity = 0.8
48
+ expect(e1.continuity_with(e2)).to be_within(0.001).of(0.8)
49
+ end
50
+
51
+ it 'is symmetric' do
52
+ e1 = described_class.new(content: 'x', dimension_values: { space: 0.2, time: 0.8, causation: 0.5, intentionality: 0.3, protagonist: 0.7 })
53
+ e2 = described_class.new(content: 'y', dimension_values: { space: 0.6, time: 0.4, causation: 0.9, intentionality: 0.1, protagonist: 0.5 })
54
+ expect(e1.continuity_with(e2)).to be_within(0.001).of(e2.continuity_with(e1))
55
+ end
56
+ end
57
+
58
+ describe '#discontinuous_dimensions' do
59
+ it 'returns empty for identical events' do
60
+ expect(event_a.discontinuous_dimensions(event_b)).to be_empty
61
+ end
62
+
63
+ it 'returns dimensions that exceed threshold' do
64
+ e1 = described_class.new(content: 'a', dimension_values: { space: 0.0, time: 0.5, causation: 0.5, intentionality: 0.5, protagonist: 0.5 })
65
+ e2 = described_class.new(content: 'b', dimension_values: { space: 0.8, time: 0.5, causation: 0.5, intentionality: 0.5, protagonist: 0.5 })
66
+ result = e1.discontinuous_dimensions(e2, threshold: 0.3)
67
+ expect(result).to include(:space)
68
+ expect(result).not_to include(:time, :causation, :intentionality, :protagonist)
69
+ end
70
+
71
+ it 'uses threshold 0.3 by default' do
72
+ e1 = described_class.new(content: 'a', dimension_values: { space: 0.0, time: 0.5, causation: 0.5, intentionality: 0.5, protagonist: 0.5 })
73
+ e2 = described_class.new(content: 'b', dimension_values: { space: 0.5, time: 0.5, causation: 0.5, intentionality: 0.5, protagonist: 0.5 })
74
+ # space differs by 0.5 > 0.3
75
+ expect(e1.discontinuous_dimensions(e2)).to include(:space)
76
+ end
77
+
78
+ it 'respects custom threshold' do
79
+ e1 = described_class.new(content: 'a', dimension_values: { space: 0.0, time: 0.5, causation: 0.5, intentionality: 0.5, protagonist: 0.5 })
80
+ e2 = described_class.new(content: 'b', dimension_values: { space: 0.4, time: 0.5, causation: 0.5, intentionality: 0.5, protagonist: 0.5 })
81
+ # space diff = 0.4, with threshold 0.5 should NOT be discontinuous
82
+ expect(e1.discontinuous_dimensions(e2, threshold: 0.5)).not_to include(:space)
83
+ end
84
+ end
85
+
86
+ describe '#to_h' do
87
+ it 'includes content, dimension_values, and created_at' do
88
+ h = event_a.to_h
89
+ expect(h[:content]).to eq('hero walks into room')
90
+ expect(h[:dimension_values]).to be_a(Hash)
91
+ expect(h[:created_at]).to be_a(String)
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,235 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::SituationModel::Helpers::SituationModel do
4
+ let(:model) { described_class.new(label: 'test_narrative') }
5
+
6
+ let(:event_a) do
7
+ Legion::Extensions::SituationModel::Helpers::SituationEvent.new(
8
+ content: 'opening scene',
9
+ dimension_values: { space: 0.8, time: 0.8, causation: 0.8, intentionality: 0.8, protagonist: 0.8 }
10
+ )
11
+ end
12
+
13
+ let(:event_b) do
14
+ Legion::Extensions::SituationModel::Helpers::SituationEvent.new(
15
+ content: 'continuing scene',
16
+ dimension_values: { space: 0.8, time: 0.9, causation: 0.8, intentionality: 0.7, protagonist: 0.8 }
17
+ )
18
+ end
19
+
20
+ let(:event_distant) do
21
+ Legion::Extensions::SituationModel::Helpers::SituationEvent.new(
22
+ content: 'teleport scene',
23
+ dimension_values: { space: 0.0, time: 0.0, causation: 0.0, intentionality: 0.0, protagonist: 0.0 }
24
+ )
25
+ end
26
+
27
+ describe '#initialize' do
28
+ it 'assigns a UUID id' do
29
+ expect(model.id).to match(/\A[0-9a-f-]{36}\z/)
30
+ end
31
+
32
+ it 'stores label' do
33
+ expect(model.label).to eq('test_narrative')
34
+ end
35
+
36
+ it 'starts with empty events' do
37
+ expect(model.events).to be_empty
38
+ end
39
+
40
+ it 'initializes current_state with default values' do
41
+ Legion::Extensions::SituationModel::Helpers::Constants::DIMENSIONS.each do |dim|
42
+ expect(model.current_state[dim]).to eq(0.5)
43
+ end
44
+ end
45
+ end
46
+
47
+ describe '#add_event' do
48
+ it 'appends event to events array' do
49
+ model.add_event(event_a)
50
+ expect(model.events.size).to eq(1)
51
+ end
52
+
53
+ it 'returns 1.0 for first event (no previous)' do
54
+ result = model.add_event(event_a)
55
+ expect(result).to eq(1.0)
56
+ end
57
+
58
+ it 'returns continuity score for subsequent events' do
59
+ model.add_event(event_a)
60
+ result = model.add_event(event_b)
61
+ expect(result).to be_a(Float)
62
+ expect(result).to be_between(0.0, 1.0)
63
+ end
64
+
65
+ it 'updates current_state to the new event dimension values' do
66
+ model.add_event(event_a)
67
+ expect(model.current_state[:space]).to be_within(0.001).of(0.8)
68
+ end
69
+
70
+ it 'updates last_updated_at' do
71
+ original = model.last_updated_at
72
+ sleep(0.01)
73
+ model.add_event(event_a)
74
+ expect(model.last_updated_at).to be >= original
75
+ end
76
+ end
77
+
78
+ describe '#coherence' do
79
+ it 'returns 1.0 for empty model' do
80
+ expect(model.coherence).to eq(1.0)
81
+ end
82
+
83
+ it 'returns 1.0 for single event' do
84
+ model.add_event(event_a)
85
+ expect(model.coherence).to eq(1.0)
86
+ end
87
+
88
+ it 'returns high coherence for similar events' do
89
+ model.add_event(event_a)
90
+ model.add_event(event_b)
91
+ expect(model.coherence).to be > 0.8
92
+ end
93
+
94
+ it 'returns lower coherence for dissimilar events' do
95
+ model.add_event(event_a)
96
+ model.add_event(event_distant)
97
+ expect(model.coherence).to be < 0.5
98
+ end
99
+
100
+ it 'is between 0 and 1' do
101
+ model.add_event(event_a)
102
+ model.add_event(event_b)
103
+ model.add_event(event_distant)
104
+ expect(model.coherence).to be_between(0.0, 1.0)
105
+ end
106
+ end
107
+
108
+ describe '#health_label' do
109
+ it 'returns :vivid for fresh model with coherent events' do
110
+ model.add_event(event_a)
111
+ model.add_event(event_b)
112
+ expect(model.health_label).to eq(:vivid)
113
+ end
114
+
115
+ it 'returns a known health symbol' do
116
+ labels = %i[vivid clear hazy fading collapsed]
117
+ expect(labels).to include(model.health_label)
118
+ end
119
+ end
120
+
121
+ describe '#dominant_dimension' do
122
+ it 'returns the dimension with highest current_state value' do
123
+ model.add_event(
124
+ Legion::Extensions::SituationModel::Helpers::SituationEvent.new(
125
+ content: 'high space',
126
+ dimension_values: { space: 0.9, time: 0.3, causation: 0.4, intentionality: 0.2, protagonist: 0.1 }
127
+ )
128
+ )
129
+ expect(model.dominant_dimension).to eq(:space)
130
+ end
131
+ end
132
+
133
+ describe '#weakest_dimension' do
134
+ it 'returns the dimension with lowest current_state value' do
135
+ model.add_event(
136
+ Legion::Extensions::SituationModel::Helpers::SituationEvent.new(
137
+ content: 'weak protagonist',
138
+ dimension_values: { space: 0.9, time: 0.8, causation: 0.7, intentionality: 0.6, protagonist: 0.1 }
139
+ )
140
+ )
141
+ expect(model.weakest_dimension).to eq(:protagonist)
142
+ end
143
+ end
144
+
145
+ describe '#event_boundaries' do
146
+ it 'returns empty array when no boundaries' do
147
+ model.add_event(event_a)
148
+ model.add_event(event_b)
149
+ # Events are similar - expect no or minimal boundaries
150
+ result = model.event_boundaries(threshold: 0.5)
151
+ expect(result).to be_an(Array)
152
+ end
153
+
154
+ it 'detects boundaries on large dimension shifts' do
155
+ model.add_event(event_a)
156
+ model.add_event(event_distant)
157
+ # event_distant is all zeros vs event_a all 0.8 - should produce boundary
158
+ boundaries = model.event_boundaries(threshold: 0.3)
159
+ expect(boundaries).to include(1)
160
+ end
161
+
162
+ it 'uses threshold 0.3 by default' do
163
+ model.add_event(event_a)
164
+ model.add_event(event_distant)
165
+ boundaries_default = model.event_boundaries
166
+ boundaries_strict = model.event_boundaries(threshold: 0.9)
167
+ # strict threshold should catch fewer boundaries
168
+ expect(boundaries_default.size).to be >= boundaries_strict.size
169
+ end
170
+ end
171
+
172
+ describe '#dimension_trajectory' do
173
+ it 'returns an array of values for each event' do
174
+ model.add_event(event_a)
175
+ model.add_event(event_b)
176
+ trajectory = model.dimension_trajectory(:space)
177
+ expect(trajectory.size).to eq(2)
178
+ end
179
+
180
+ it 'contains the correct values in order' do
181
+ model.add_event(event_a)
182
+ model.add_event(event_b)
183
+ trajectory = model.dimension_trajectory(:space)
184
+ expect(trajectory[0]).to be_within(0.001).of(0.8)
185
+ expect(trajectory[1]).to be_within(0.001).of(0.8)
186
+ end
187
+
188
+ it 'returns empty for model with no events' do
189
+ expect(model.dimension_trajectory(:time)).to be_empty
190
+ end
191
+ end
192
+
193
+ describe '#decay!' do
194
+ it 'reduces all current_state values by DECAY_RATE' do
195
+ model.add_event(event_a)
196
+ before = model.current_state[:space]
197
+ model.decay!
198
+ expect(model.current_state[:space]).to be_within(0.001).of(before - 0.01)
199
+ end
200
+
201
+ it 'clamps at 0.0' do
202
+ low_event = Legion::Extensions::SituationModel::Helpers::SituationEvent.new(
203
+ content: 'near zero',
204
+ dimension_values: { space: 0.005, time: 0.5, causation: 0.5, intentionality: 0.5, protagonist: 0.5 }
205
+ )
206
+ model.add_event(low_event)
207
+ model.decay!
208
+ expect(model.current_state[:space]).to be >= 0.0
209
+ end
210
+
211
+ it 'applies decay to all 5 dimensions' do
212
+ model.add_event(event_a)
213
+ before = model.current_state.dup
214
+ model.decay!
215
+ Legion::Extensions::SituationModel::Helpers::Constants::DIMENSIONS.each do |dim|
216
+ expect(model.current_state[dim]).to be < before[dim]
217
+ end
218
+ end
219
+ end
220
+
221
+ describe '#to_h' do
222
+ it 'includes all expected keys' do
223
+ h = model.to_h
224
+ %i[id label event_count current_state coherence health_label created_at last_updated_at].each do |k|
225
+ expect(h).to have_key(k)
226
+ end
227
+ end
228
+
229
+ it 'reflects event count' do
230
+ model.add_event(event_a)
231
+ model.add_event(event_b)
232
+ expect(model.to_h[:event_count]).to eq(2)
233
+ end
234
+ end
235
+ end