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.
- checksums.yaml +7 -0
- data/Gemfile +12 -0
- data/LICENSE +21 -0
- data/README.md +75 -0
- data/lex-situation-model.gemspec +29 -0
- data/lib/legion/extensions/situation_model/client.rb +24 -0
- data/lib/legion/extensions/situation_model/helpers/client.rb +19 -0
- data/lib/legion/extensions/situation_model/helpers/constants.rb +36 -0
- data/lib/legion/extensions/situation_model/helpers/situation_engine.rb +69 -0
- data/lib/legion/extensions/situation_model/helpers/situation_event.rb +52 -0
- data/lib/legion/extensions/situation_model/helpers/situation_model.rb +86 -0
- data/lib/legion/extensions/situation_model/runners/situation_model.rb +95 -0
- data/lib/legion/extensions/situation_model/version.rb +9 -0
- data/lib/legion/extensions/situation_model.rb +18 -0
- data/spec/legion/extensions/situation_model/client_spec.rb +51 -0
- data/spec/legion/extensions/situation_model/helpers/constants_spec.rb +56 -0
- data/spec/legion/extensions/situation_model/helpers/situation_engine_spec.rb +203 -0
- data/spec/legion/extensions/situation_model/helpers/situation_event_spec.rb +94 -0
- data/spec/legion/extensions/situation_model/helpers/situation_model_spec.rb +235 -0
- data/spec/legion/extensions/situation_model/runners/situation_model_spec.rb +204 -0
- data/spec/spec_helper.rb +23 -0
- metadata +81 -0
|
@@ -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
|