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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 41d37b1bbd266913da91713adc534e8e67746991a81c7f5663e3f15693e7aff3
4
+ data.tar.gz: c81dfe9ec810c628d8ce80a8f1e5189965b4abfc9e12850c4510abf066d8565a
5
+ SHA512:
6
+ metadata.gz: 1bd7e624765b6fb0af200387fc1825097f59a6b28682e1d87924efefd49b7eb043007a17a306dd617217b29f5e7508bc5927d04defbac7adcd29e9c725d884d2
7
+ data.tar.gz: e7c55171a5db8529e7c9a00f741d8e7640cda51da3e7e89dc0f3a7a1533a3a277465698d8fa6fc7324716332d26c120576ba5569b6fe77e61b00894d8a242ecb
data/Gemfile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+ gemspec
5
+
6
+ group :test do
7
+ gem 'rspec', '~> 3.13'
8
+ gem 'rubocop', '~> 1.75', require: false
9
+ gem 'rubocop-rspec', require: false
10
+ end
11
+
12
+ gem 'legion-gaia', path: '../../legion-gaia'
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Matthew Iverson
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,75 @@
1
+ # lex-situation-model
2
+
3
+ Five-dimension situation tracking for LegionIO cognitive agents. Models narrative coherence and event boundaries using Zwaan & Radvansky's situational indexing dimensions.
4
+
5
+ ## What It Does
6
+
7
+ `lex-situation-model` tracks sequences of events scored across five dimensions — space, time, causation, intentionality, and protagonist. Events within a situation are compared pairwise for continuity; sharp discontinuities mark situation boundaries (the cognitive equivalent of a scene change). Situations are scored for overall coherence and can be ranked by health.
8
+
9
+ - **Dimensions**: `:space`, `:time`, `:causation`, `:intentionality`, `:protagonist` (each 0.0–1.0)
10
+ - **Continuity**: `1.0 - mean_dimension_diff` between consecutive events (1.0 = identical, 0.0 = completely different)
11
+ - **Event boundaries**: indices where continuity drops below a configurable threshold (default 0.4)
12
+ - **Coherence**: mean pairwise continuity across the full event sequence
13
+ - **Health labels**: `:stable` (coherent), `:degrading`, `:fragmented`
14
+ - **Decay**: coherence degrades passively each tick
15
+
16
+ ## Usage
17
+
18
+ ```ruby
19
+ require 'legion/extensions/situation_model'
20
+
21
+ client = Legion::Extensions::SituationModel::Client.new
22
+
23
+ # Create a situation
24
+ result = client.create_situation_model(name: 'office_meeting', domain: :work)
25
+ model_id = result[:model_id]
26
+
27
+ # Add events with dimension scores
28
+ client.add_situation_event(
29
+ model_id: model_id,
30
+ description: 'team arrives',
31
+ space: 0.8, time: 0.2, causation: 0.5, intentionality: 0.7, protagonist: 0.9
32
+ )
33
+ # => { continuity: 1.0 } (first event)
34
+
35
+ client.add_situation_event(
36
+ model_id: model_id,
37
+ description: 'discussion begins',
38
+ space: 0.8, time: 0.3, causation: 0.6, intentionality: 0.8, protagonist: 0.9
39
+ )
40
+ # => { continuity: 0.94 } (high — same space, same protagonists)
41
+
42
+ client.add_situation_event(
43
+ model_id: model_id,
44
+ description: 'unexpected fire alarm',
45
+ space: 0.1, time: 0.3, causation: 0.1, intentionality: 0.1, protagonist: 0.5
46
+ )
47
+ # => { continuity: 0.42 } (low — space and intentionality shifted sharply)
48
+
49
+ # Check overall coherence
50
+ client.situation_model_coherence(model_id: model_id)
51
+ # => { coherence: 0.71, health_label: :stable }
52
+
53
+ # Find event boundaries
54
+ client.find_situation_boundaries(model_id: model_id, threshold: 0.4)
55
+ # => { boundaries: [2], count: 1 }
56
+
57
+ # Trace how time dimension evolved
58
+ client.situation_dimension_trajectory(model_id: model_id, dimension: :time)
59
+ # => { trajectory: [0.2, 0.3, 0.3] }
60
+
61
+ # Per-tick decay
62
+ client.update_situation_models
63
+ ```
64
+
65
+ ## Development
66
+
67
+ ```bash
68
+ bundle install
69
+ bundle exec rspec
70
+ bundle exec rubocop
71
+ ```
72
+
73
+ ## License
74
+
75
+ MIT
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'lib/legion/extensions/situation_model/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'lex-situation-model'
7
+ spec.version = Legion::Extensions::SituationModel::VERSION
8
+ spec.authors = ['Esity']
9
+ spec.email = ['matthewdiverson@gmail.com']
10
+
11
+ spec.summary = 'LEX Situation Model'
12
+ spec.description = 'Zwaan Event Indexing Model for brain-modeled agentic AI: tracks 5-dimensional situation models across narrative events'
13
+ spec.homepage = 'https://github.com/LegionIO/lex-situation-model'
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-situation-model'
19
+ spec.metadata['documentation_uri'] = 'https://github.com/LegionIO/lex-situation-model'
20
+ spec.metadata['changelog_uri'] = 'https://github.com/LegionIO/lex-situation-model'
21
+ spec.metadata['bug_tracker_uri'] = 'https://github.com/LegionIO/lex-situation-model/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-situation-model.gemspec Gemfile LICENSE README.md]
26
+ end
27
+ spec.require_paths = ['lib']
28
+ spec.add_development_dependency 'legion-gaia'
29
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/situation_model/helpers/constants'
4
+ require 'legion/extensions/situation_model/helpers/situation_event'
5
+ require 'legion/extensions/situation_model/helpers/situation_model'
6
+ require 'legion/extensions/situation_model/helpers/situation_engine'
7
+ require 'legion/extensions/situation_model/runners/situation_model'
8
+ require 'legion/extensions/situation_model/helpers/client'
9
+
10
+ module Legion
11
+ module Extensions
12
+ module SituationModel
13
+ class Client
14
+ include Runners::SituationModel
15
+
16
+ private
17
+
18
+ def engine
19
+ @engine ||= Helpers::SituationEngine.new
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module SituationModel
6
+ module Helpers
7
+ class Client
8
+ include Legion::Extensions::SituationModel::Runners::SituationModel
9
+
10
+ private
11
+
12
+ def engine
13
+ @engine ||= SituationEngine.new
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module SituationModel
6
+ module Helpers
7
+ module Constants
8
+ DIMENSIONS = %i[space time causation intentionality protagonist].freeze
9
+
10
+ CONTINUITY_LABELS = {
11
+ (0.8..) => :continuous,
12
+ (0.5...0.8) => :shift,
13
+ (0.2...0.5) => :break,
14
+ (..0.2) => :rupture
15
+ }.freeze
16
+
17
+ MODEL_HEALTH_LABELS = {
18
+ (0.8..) => :vivid,
19
+ (0.6...0.8) => :clear,
20
+ (0.4...0.6) => :hazy,
21
+ (0.2...0.4) => :fading,
22
+ (..0.2) => :collapsed
23
+ }.freeze
24
+
25
+ MAX_MODELS = 100
26
+ MAX_EVENTS_PER_MODEL = 200
27
+ MAX_HISTORY = 500
28
+ DEFAULT_DIMENSION_VALUE = 0.5
29
+ DECAY_RATE = 0.01
30
+ COHERENCE_FLOOR = 0.0
31
+ COHERENCE_CEILING = 1.0
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module SituationModel
6
+ module Helpers
7
+ class SituationEngine
8
+ include Constants
9
+
10
+ def initialize
11
+ @models = {}
12
+ end
13
+
14
+ def create_model(label:)
15
+ model = SituationModel.new(label: label)
16
+ @models[model.id] = model
17
+ model
18
+ end
19
+
20
+ def add_event_to_model(model_id:, content:, dimension_values: {})
21
+ model = @models[model_id]
22
+ return nil unless model
23
+
24
+ event = SituationEvent.new(content: content, dimension_values: dimension_values)
25
+ model.add_event(event)
26
+ event
27
+ end
28
+
29
+ def model_coherence(model_id:)
30
+ @models[model_id]&.coherence
31
+ end
32
+
33
+ def find_boundaries(model_id:, threshold: 0.3)
34
+ @models[model_id]&.event_boundaries(threshold: threshold)
35
+ end
36
+
37
+ def dimension_trajectory(model_id:, dimension:)
38
+ @models[model_id]&.dimension_trajectory(dimension)
39
+ end
40
+
41
+ def most_coherent(limit: 5)
42
+ @models.values
43
+ .sort_by { |m| -m.coherence }
44
+ .first(limit)
45
+ end
46
+
47
+ def models_by_label(label:)
48
+ @models.values.select { |m| m.label == label }
49
+ end
50
+
51
+ def decay_all
52
+ @models.each_value(&:decay!)
53
+ end
54
+
55
+ def prune_collapsed
56
+ @models.delete_if { |_, m| m.coherence <= 0.1 }
57
+ end
58
+
59
+ def to_h
60
+ {
61
+ model_count: @models.size,
62
+ models: @models.values.map(&:to_h)
63
+ }
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module SituationModel
6
+ module Helpers
7
+ class SituationEvent
8
+ include Constants
9
+
10
+ attr_reader :content, :dimension_values, :created_at
11
+
12
+ def initialize(content:, dimension_values: {})
13
+ @content = content
14
+ @dimension_values = build_dimension_values(dimension_values)
15
+ @created_at = Time.now.utc
16
+ end
17
+
18
+ def continuity_with(other_event)
19
+ total_diff = DIMENSIONS.sum do |dim|
20
+ (dimension_values[dim] - other_event.dimension_values[dim]).abs
21
+ end
22
+ avg_diff = total_diff / DIMENSIONS.size.to_f
23
+ 1.0 - avg_diff
24
+ end
25
+
26
+ def discontinuous_dimensions(other_event, threshold: 0.3)
27
+ DIMENSIONS.select do |dim|
28
+ (dimension_values[dim] - other_event.dimension_values[dim]).abs > threshold
29
+ end
30
+ end
31
+
32
+ def to_h
33
+ {
34
+ content: content,
35
+ dimension_values: dimension_values,
36
+ created_at: created_at.iso8601
37
+ }
38
+ end
39
+
40
+ private
41
+
42
+ def build_dimension_values(values)
43
+ DIMENSIONS.to_h do |dim|
44
+ raw = values.fetch(dim, DEFAULT_DIMENSION_VALUE)
45
+ [dim, raw.clamp(0.0, 1.0)]
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module Legion
6
+ module Extensions
7
+ module SituationModel
8
+ module Helpers
9
+ class SituationModel
10
+ include Constants
11
+
12
+ attr_reader :id, :label, :events, :current_state, :created_at, :last_updated_at
13
+
14
+ def initialize(label:)
15
+ @id = SecureRandom.uuid
16
+ @label = label
17
+ @events = []
18
+ @current_state = DIMENSIONS.to_h { |dim| [dim, DEFAULT_DIMENSION_VALUE] }
19
+ @created_at = Time.now.utc
20
+ @last_updated_at = Time.now.utc
21
+ end
22
+
23
+ def add_event(event)
24
+ previous = events.last
25
+ events << event
26
+ @current_state = event.dimension_values.dup
27
+ @last_updated_at = Time.now.utc
28
+ previous ? event.continuity_with(previous) : 1.0
29
+ end
30
+
31
+ def coherence
32
+ return 1.0 if events.size <= 1
33
+
34
+ pairs = events.each_cons(2).to_a
35
+ total = pairs.sum { |a, b| b.continuity_with(a) }
36
+ (total / pairs.size.to_f).clamp(COHERENCE_FLOOR, COHERENCE_CEILING)
37
+ end
38
+
39
+ def health_label
40
+ c = coherence
41
+ MODEL_HEALTH_LABELS.find { |range, _| range.cover?(c) }&.last || :collapsed
42
+ end
43
+
44
+ def dominant_dimension
45
+ current_state.max_by { |_, v| v }&.first
46
+ end
47
+
48
+ def weakest_dimension
49
+ current_state.min_by { |_, v| v }&.first
50
+ end
51
+
52
+ def event_boundaries(threshold: 0.3)
53
+ indices = []
54
+ events.each_cons(2).with_index do |(a, b), idx|
55
+ indices << (idx + 1) unless b.discontinuous_dimensions(a, threshold: threshold).empty?
56
+ end
57
+ indices
58
+ end
59
+
60
+ def dimension_trajectory(dimension)
61
+ events.map { |e| e.dimension_values[dimension] }
62
+ end
63
+
64
+ def decay!
65
+ DIMENSIONS.each do |dim|
66
+ current_state[dim] = (current_state[dim] - DECAY_RATE).clamp(COHERENCE_FLOOR, COHERENCE_CEILING)
67
+ end
68
+ end
69
+
70
+ def to_h
71
+ {
72
+ id: id,
73
+ label: label,
74
+ event_count: events.size,
75
+ current_state: current_state,
76
+ coherence: coherence,
77
+ health_label: health_label,
78
+ created_at: created_at.iso8601,
79
+ last_updated_at: last_updated_at.iso8601
80
+ }
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module SituationModel
6
+ module Runners
7
+ module SituationModel
8
+ include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers) &&
9
+ Legion::Extensions::Helpers.const_defined?(:Lex)
10
+
11
+ def create_situation_model(label:, **)
12
+ model = engine.create_model(label: label)
13
+ Legion::Logging.debug "[situation_model] create_model: id=#{model.id} label=#{label}"
14
+ { success: true, model: model.to_h }
15
+ end
16
+
17
+ def add_situation_event(model_id:, content:, **opts)
18
+ dim_values = {
19
+ space: opts.fetch(:space, 0.5),
20
+ time: opts.fetch(:time, 0.5),
21
+ causation: opts.fetch(:causation, 0.5),
22
+ intentionality: opts.fetch(:intentionality, 0.5),
23
+ protagonist: opts.fetch(:protagonist, 0.5)
24
+ }
25
+ event = engine.add_event_to_model(model_id: model_id, content: content, dimension_values: dim_values)
26
+ unless event
27
+ Legion::Logging.debug "[situation_model] add_event: model_id=#{model_id} not found"
28
+ return { success: false, error: 'model not found' }
29
+ end
30
+
31
+ coherence = engine.model_coherence(model_id: model_id)
32
+ Legion::Logging.debug "[situation_model] add_event: model_id=#{model_id} coherence=#{coherence.round(3)}"
33
+ { success: true, event: event.to_h, coherence: coherence }
34
+ end
35
+
36
+ def situation_model_coherence(model_id:, **)
37
+ coherence = engine.model_coherence(model_id: model_id)
38
+ Legion::Logging.debug "[situation_model] coherence: model_id=#{model_id} value=#{coherence}"
39
+ return { success: false, error: 'model not found' } if coherence.nil?
40
+
41
+ { success: true, model_id: model_id, coherence: coherence }
42
+ end
43
+
44
+ def find_situation_boundaries(model_id:, threshold: 0.3, **)
45
+ boundaries = engine.find_boundaries(model_id: model_id, threshold: threshold)
46
+ Legion::Logging.debug "[situation_model] boundaries: model_id=#{model_id} count=#{boundaries&.size}"
47
+ return { success: false, error: 'model not found' } if boundaries.nil?
48
+
49
+ { success: true, model_id: model_id, boundaries: boundaries, threshold: threshold }
50
+ end
51
+
52
+ def situation_dimension_trajectory(model_id:, dimension:, **)
53
+ dim = dimension.to_sym
54
+ trajectory = engine.dimension_trajectory(model_id: model_id, dimension: dim)
55
+ Legion::Logging.debug "[situation_model] trajectory: model_id=#{model_id} dimension=#{dim} points=#{trajectory&.size}"
56
+ return { success: false, error: 'model not found' } if trajectory.nil?
57
+
58
+ { success: true, model_id: model_id, dimension: dim, trajectory: trajectory }
59
+ end
60
+
61
+ def most_coherent_situations(limit: 5, **)
62
+ models = engine.most_coherent(limit: limit)
63
+ Legion::Logging.debug "[situation_model] most_coherent: limit=#{limit} found=#{models.size}"
64
+ { success: true, models: models.map(&:to_h), count: models.size }
65
+ end
66
+
67
+ def situations_by_label(label:, **)
68
+ models = engine.models_by_label(label: label)
69
+ Legion::Logging.debug "[situation_model] by_label: label=#{label} found=#{models.size}"
70
+ { success: true, label: label, models: models.map(&:to_h), count: models.size }
71
+ end
72
+
73
+ def update_situation_models(**)
74
+ engine.decay_all
75
+ pruned = engine.prune_collapsed
76
+ Legion::Logging.debug "[situation_model] update: decay_all pruned=#{pruned.size}"
77
+ { success: true, pruned_count: pruned.size }
78
+ end
79
+
80
+ def situation_model_stats(**)
81
+ stats = engine.to_h
82
+ Legion::Logging.debug "[situation_model] stats: model_count=#{stats[:model_count]}"
83
+ { success: true, **stats }
84
+ end
85
+
86
+ private
87
+
88
+ def engine
89
+ @engine ||= Helpers::SituationEngine.new
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module SituationModel
6
+ VERSION = '0.1.0'
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/situation_model/version'
4
+ require 'legion/extensions/situation_model/helpers/constants'
5
+ require 'legion/extensions/situation_model/helpers/situation_event'
6
+ require 'legion/extensions/situation_model/helpers/situation_model'
7
+ require 'legion/extensions/situation_model/helpers/situation_engine'
8
+ require 'legion/extensions/situation_model/runners/situation_model'
9
+ require 'legion/extensions/situation_model/helpers/client'
10
+ require 'legion/extensions/situation_model/client'
11
+
12
+ module Legion
13
+ module Extensions
14
+ module SituationModel
15
+ extend Legion::Extensions::Core if Legion::Extensions.const_defined? :Core
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/situation_model/client'
4
+
5
+ RSpec.describe Legion::Extensions::SituationModel::Client do
6
+ let(:client) { described_class.new }
7
+
8
+ it 'responds to all runner methods' do
9
+ %i[
10
+ create_situation_model
11
+ add_situation_event
12
+ situation_model_coherence
13
+ find_situation_boundaries
14
+ situation_dimension_trajectory
15
+ most_coherent_situations
16
+ situations_by_label
17
+ update_situation_models
18
+ situation_model_stats
19
+ ].each do |method_name|
20
+ expect(client).to respond_to(method_name)
21
+ end
22
+ end
23
+
24
+ it 'round-trips a full situation model lifecycle' do
25
+ created = client.create_situation_model(label: 'round_trip')
26
+ model_id = created[:model][:id]
27
+
28
+ client.add_situation_event(model_id: model_id, content: 'scene 1',
29
+ space: 0.8, time: 0.8, causation: 0.8, intentionality: 0.7, protagonist: 0.9)
30
+ client.add_situation_event(model_id: model_id, content: 'scene 2',
31
+ space: 0.7, time: 0.9, causation: 0.8, intentionality: 0.7, protagonist: 0.8)
32
+
33
+ coh = client.situation_model_coherence(model_id: model_id)
34
+ expect(coh[:success]).to be(true)
35
+ expect(coh[:coherence]).to be > 0.7
36
+
37
+ traj = client.situation_dimension_trajectory(model_id: model_id, dimension: :time)
38
+ expect(traj[:trajectory]).to eq([0.8, 0.9])
39
+
40
+ stats = client.situation_model_stats
41
+ expect(stats[:model_count]).to be >= 1
42
+ end
43
+
44
+ it 'maintains separate engine state per instance' do
45
+ c1 = described_class.new
46
+ c2 = described_class.new
47
+ c1.create_situation_model(label: 'only_c1')
48
+ expect(c1.situation_model_stats[:model_count]).to eq(1)
49
+ expect(c2.situation_model_stats[:model_count]).to eq(0)
50
+ end
51
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::SituationModel::Helpers::Constants do
4
+ it 'defines 5 dimensions' do
5
+ expect(described_class::DIMENSIONS.size).to eq(5)
6
+ expect(described_class::DIMENSIONS).to include(:space, :time, :causation, :intentionality, :protagonist)
7
+ end
8
+
9
+ it 'CONTINUITY_LABELS covers the full 0..1 range' do
10
+ [0.0, 0.1, 0.2, 0.3, 0.5, 0.6, 0.8, 0.9, 1.0].each do |v|
11
+ match = described_class::CONTINUITY_LABELS.find { |range, _| range.cover?(v) }
12
+ expect(match).not_to be_nil, "no label for #{v}"
13
+ end
14
+ end
15
+
16
+ it 'assigns :rupture for very low continuity' do
17
+ label = described_class::CONTINUITY_LABELS.find { |r, _| r.cover?(0.1) }&.last
18
+ expect(label).to eq(:rupture)
19
+ end
20
+
21
+ it 'assigns :continuous for high continuity' do
22
+ label = described_class::CONTINUITY_LABELS.find { |r, _| r.cover?(0.9) }&.last
23
+ expect(label).to eq(:continuous)
24
+ end
25
+
26
+ it 'assigns :shift for mid-range continuity' do
27
+ label = described_class::CONTINUITY_LABELS.find { |r, _| r.cover?(0.6) }&.last
28
+ expect(label).to eq(:shift)
29
+ end
30
+
31
+ it 'MODEL_HEALTH_LABELS covers full range' do
32
+ [0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 1.0].each do |v|
33
+ match = described_class::MODEL_HEALTH_LABELS.find { |range, _| range.cover?(v) }
34
+ expect(match).not_to be_nil, "no health label for #{v}"
35
+ end
36
+ end
37
+
38
+ it 'assigns :vivid for coherence >= 0.8' do
39
+ label = described_class::MODEL_HEALTH_LABELS.find { |r, _| r.cover?(0.85) }&.last
40
+ expect(label).to eq(:vivid)
41
+ end
42
+
43
+ it 'assigns :collapsed for coherence <= 0.2' do
44
+ label = described_class::MODEL_HEALTH_LABELS.find { |r, _| r.cover?(0.05) }&.last
45
+ expect(label).to eq(:collapsed)
46
+ end
47
+
48
+ it 'has sensible numeric constants' do
49
+ expect(described_class::MAX_MODELS).to eq(100)
50
+ expect(described_class::MAX_EVENTS_PER_MODEL).to eq(200)
51
+ expect(described_class::DECAY_RATE).to eq(0.01)
52
+ expect(described_class::DEFAULT_DIMENSION_VALUE).to eq(0.5)
53
+ expect(described_class::COHERENCE_FLOOR).to eq(0.0)
54
+ expect(described_class::COHERENCE_CEILING).to eq(1.0)
55
+ end
56
+ end