lex-narrative-reasoning 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: 234a1fb4d137c47def7053f94c85de48519c5dc129f96dc4431b3e2d493da2de
4
+ data.tar.gz: 7960f4ebc954519fe45ac8bddcfbddf5d3c1456e24c24dcfc80ce41fd1b12e74
5
+ SHA512:
6
+ metadata.gz: 5a5e7533a37b1c9677bd4619b1ae52cfaf9596727c25b242a6b33ca022c8654954cb26b10f6f747b742a071988045a75a3b07f2cc19c90054fd9ab8204fea2e3
7
+ data.tar.gz: 251d0440acc69a6fdcb4e4be5f6afe361f24490df56e35e4b439f299a8ab705fdaa8c0227457a8d84f71efb042b61d5fbf2080afa538feb4c61317ef3bb8f453
data/Gemfile ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ gemspec
6
+
7
+ gem 'rspec', '~> 3.13'
8
+ gem 'rubocop', '~> 1.75', require: false
9
+ gem 'rubocop-rspec', require: false
10
+
11
+ gem 'legion-gaia', path: '../../legion-gaia'
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'lib/legion/extensions/narrative_reasoning/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'lex-narrative-reasoning'
7
+ spec.version = Legion::Extensions::NarrativeReasoning::VERSION
8
+ spec.authors = ['Esity']
9
+ spec.email = ['matthewdiverson@gmail.com']
10
+
11
+ spec.summary = 'LEX Narrative Reasoning'
12
+ spec.description = 'Narrative reasoning engine for brain-modeled agentic AI — story structure, causal chains, arc progression'
13
+ spec.homepage = 'https://github.com/LegionIO/lex-narrative-reasoning'
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-narrative-reasoning'
19
+ spec.metadata['documentation_uri'] = 'https://github.com/LegionIO/lex-narrative-reasoning'
20
+ spec.metadata['changelog_uri'] = 'https://github.com/LegionIO/lex-narrative-reasoning'
21
+ spec.metadata['bug_tracker_uri'] = 'https://github.com/LegionIO/lex-narrative-reasoning/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-narrative-reasoning.gemspec Gemfile]
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/narrative_reasoning/helpers/narrative_event'
4
+ require 'legion/extensions/narrative_reasoning/helpers/narrative'
5
+ require 'legion/extensions/narrative_reasoning/helpers/narrative_engine'
6
+ require 'legion/extensions/narrative_reasoning/runners/narrative_reasoning'
7
+
8
+ module Legion
9
+ module Extensions
10
+ module NarrativeReasoning
11
+ class Client
12
+ include Runners::NarrativeReasoning
13
+
14
+ def initialize(**)
15
+ @narrative_engine = Helpers::NarrativeEngine.new
16
+ end
17
+
18
+ private
19
+
20
+ attr_reader :narrative_engine
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module Legion
6
+ module Extensions
7
+ module NarrativeReasoning
8
+ module Helpers
9
+ class Narrative
10
+ ARC_STAGES = %i[beginning rising_action climax falling_action resolution].freeze
11
+
12
+ COHERENCE_LABELS = {
13
+ (0.8..) => :compelling,
14
+ (0.6...0.8) => :coherent,
15
+ (0.4...0.6) => :developing,
16
+ (0.2...0.4) => :fragmented,
17
+ (..0.2) => :incoherent
18
+ }.freeze
19
+
20
+ DEFAULT_COHERENCE = 0.5
21
+ COHERENCE_FLOOR = 0.0
22
+ COHERENCE_CEILING = 1.0
23
+ COHERENCE_BOOST = 0.1
24
+ DECAY_RATE = 0.02
25
+
26
+ attr_reader :id, :title, :domain, :events, :characters, :themes,
27
+ :arc_stage, :coherence, :created_at, :last_updated_at
28
+
29
+ def initialize(title:, domain: nil, id: nil)
30
+ @id = id || SecureRandom.uuid
31
+ @title = title
32
+ @domain = domain
33
+ @events = []
34
+ @characters = []
35
+ @themes = []
36
+ @arc_stage = ARC_STAGES.first
37
+ @coherence = DEFAULT_COHERENCE
38
+ @created_at = Time.now.utc
39
+ @last_updated_at = Time.now.utc
40
+ end
41
+
42
+ def add_event(event)
43
+ @events << event
44
+ event.characters.each { |chr| @characters << chr unless @characters.include?(chr) }
45
+ auto_advance_arc
46
+ @coherence = (@coherence + COHERENCE_BOOST).clamp(COHERENCE_FLOOR, COHERENCE_CEILING)
47
+ @last_updated_at = Time.now.utc
48
+ event
49
+ end
50
+
51
+ def add_theme(theme)
52
+ @themes << theme unless @themes.include?(theme)
53
+ @last_updated_at = Time.now.utc
54
+ theme
55
+ end
56
+
57
+ def advance_arc!
58
+ idx = ARC_STAGES.index(@arc_stage)
59
+ return @arc_stage if idx.nil? || idx >= ARC_STAGES.size - 1
60
+
61
+ @arc_stage = ARC_STAGES[idx + 1]
62
+ @last_updated_at = Time.now.utc
63
+ @arc_stage
64
+ end
65
+
66
+ def causal_chain
67
+ chain = []
68
+ @events.each do |event|
69
+ event.causes.each do |cause_id|
70
+ cause = @events.find { |e| e.id == cause_id }
71
+ chain << { cause: cause&.to_h, effect: event.to_h } if cause
72
+ end
73
+ end
74
+ chain
75
+ end
76
+
77
+ def coherence_label
78
+ COHERENCE_LABELS.find { |range, _label| range.cover?(@coherence) }&.last || :incoherent
79
+ end
80
+
81
+ def complete?
82
+ @arc_stage == :resolution
83
+ end
84
+
85
+ def decay_coherence
86
+ @coherence = (@coherence - DECAY_RATE).clamp(COHERENCE_FLOOR, COHERENCE_CEILING)
87
+ end
88
+
89
+ def to_h
90
+ {
91
+ id: @id,
92
+ title: @title,
93
+ domain: @domain,
94
+ events: @events.map(&:to_h),
95
+ characters: @characters,
96
+ themes: @themes,
97
+ arc_stage: @arc_stage,
98
+ coherence: @coherence,
99
+ coherence_label: coherence_label,
100
+ complete: complete?,
101
+ created_at: @created_at,
102
+ last_updated_at: @last_updated_at
103
+ }
104
+ end
105
+
106
+ private
107
+
108
+ def auto_advance_arc
109
+ return if complete?
110
+
111
+ events_per_stage = [(@events.size / 5.0).ceil, 1].max
112
+ stage_idx = [(@events.size / events_per_stage), ARC_STAGES.size - 1].min
113
+ @arc_stage = ARC_STAGES[stage_idx]
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module NarrativeReasoning
6
+ module Helpers
7
+ class NarrativeEngine
8
+ MAX_NARRATIVES = 100
9
+ MAX_EVENTS = 500
10
+ MAX_CHARACTERS = 200
11
+ MAX_HISTORY = 300
12
+
13
+ EVENT_TYPES = %i[action discovery conflict resolution revelation].freeze
14
+
15
+ def initialize
16
+ @narratives = {}
17
+ @event_count = 0
18
+ end
19
+
20
+ def create_narrative(title:, domain: nil)
21
+ evict_oldest_narrative if @narratives.size >= MAX_NARRATIVES
22
+ narrative = Narrative.new(title: title, domain: domain)
23
+ @narratives[narrative.id] = narrative
24
+ narrative
25
+ end
26
+
27
+ def add_narrative_event(narrative_id:, content:, event_type:, characters: [], causes: [])
28
+ narrative = @narratives[narrative_id]
29
+ return nil unless narrative
30
+ return nil unless EVENT_TYPES.include?(event_type)
31
+
32
+ domain = narrative.domain
33
+ event = NarrativeEvent.new(
34
+ content: content,
35
+ event_type: event_type,
36
+ characters: characters,
37
+ causes: causes,
38
+ domain: domain
39
+ )
40
+ @event_count += 1
41
+ evict_oldest_events(narrative) if narrative.events.size >= MAX_EVENTS
42
+ narrative.add_event(event)
43
+ end
44
+
45
+ def add_narrative_theme(narrative_id:, theme:)
46
+ narrative = @narratives[narrative_id]
47
+ return nil unless narrative
48
+
49
+ narrative.add_theme(theme)
50
+ end
51
+
52
+ def advance_narrative(narrative_id:)
53
+ narrative = @narratives[narrative_id]
54
+ return nil unless narrative
55
+
56
+ narrative.advance_arc!
57
+ end
58
+
59
+ def trace_causal_chain(narrative_id:)
60
+ narrative = @narratives[narrative_id]
61
+ return [] unless narrative
62
+
63
+ narrative.causal_chain
64
+ end
65
+
66
+ def complete_narratives
67
+ @narratives.values.select(&:complete?)
68
+ end
69
+
70
+ def by_domain(domain:)
71
+ @narratives.values.select { |n| n.domain == domain }
72
+ end
73
+
74
+ def most_coherent(limit: 5)
75
+ @narratives.values
76
+ .sort_by { |n| -n.coherence }
77
+ .first(limit)
78
+ end
79
+
80
+ def get(narrative_id)
81
+ @narratives[narrative_id]
82
+ end
83
+
84
+ def decay_all
85
+ @narratives.each_value(&:decay_coherence)
86
+ end
87
+
88
+ def count
89
+ @narratives.size
90
+ end
91
+
92
+ def total_events
93
+ @event_count
94
+ end
95
+
96
+ def to_h
97
+ {
98
+ narratives: @narratives.values.map(&:to_h),
99
+ count: @narratives.size,
100
+ total_events: @event_count
101
+ }
102
+ end
103
+
104
+ private
105
+
106
+ def evict_oldest_narrative
107
+ oldest = @narratives.values.min_by(&:created_at)
108
+ @narratives.delete(oldest.id) if oldest
109
+ end
110
+
111
+ def evict_oldest_events(narrative)
112
+ narrative.events.shift
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module Legion
6
+ module Extensions
7
+ module NarrativeReasoning
8
+ module Helpers
9
+ class NarrativeEvent
10
+ attr_reader :id, :content, :event_type, :characters, :causes, :timestamp, :domain
11
+
12
+ def initialize(content:, event_type:, characters: [], causes: [], domain: nil, id: nil, timestamp: nil)
13
+ @id = id || SecureRandom.uuid
14
+ @content = content
15
+ @event_type = event_type
16
+ @characters = Array(characters).dup
17
+ @causes = Array(causes).dup
18
+ @domain = domain
19
+ @timestamp = timestamp || Time.now.utc
20
+ end
21
+
22
+ def to_h
23
+ {
24
+ id: @id,
25
+ content: @content,
26
+ event_type: @event_type,
27
+ characters: @characters,
28
+ causes: @causes,
29
+ domain: @domain,
30
+ timestamp: @timestamp
31
+ }
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module NarrativeReasoning
6
+ module Runners
7
+ module NarrativeReasoning
8
+ include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers) &&
9
+ Legion::Extensions::Helpers.const_defined?(:Lex)
10
+
11
+ def create_narrative(title:, domain: nil, **)
12
+ narrative = narrative_engine.create_narrative(title: title, domain: domain)
13
+ Legion::Logging.debug "[narrative_reasoning] created narrative id=#{narrative.id[0..7]} title=#{title}"
14
+ { success: true, narrative_id: narrative.id, title: narrative.title, arc_stage: narrative.arc_stage }
15
+ end
16
+
17
+ def add_narrative_event(narrative_id:, content:, event_type:, characters: [], causes: [], **)
18
+ unless Helpers::NarrativeEngine::EVENT_TYPES.include?(event_type)
19
+ return { success: false, error: :invalid_event_type, valid_types: Helpers::NarrativeEngine::EVENT_TYPES }
20
+ end
21
+
22
+ event = narrative_engine.add_narrative_event(
23
+ narrative_id: narrative_id,
24
+ content: content,
25
+ event_type: event_type,
26
+ characters: characters,
27
+ causes: causes
28
+ )
29
+
30
+ if event
31
+ Legion::Logging.debug "[narrative_reasoning] event added id=#{event.id[0..7]} type=#{event_type}"
32
+ { success: true, event_id: event.id, event_type: event_type, narrative_id: narrative_id }
33
+ else
34
+ Legion::Logging.debug "[narrative_reasoning] add_event failed: narrative #{narrative_id[0..7]} not found"
35
+ { success: false, error: :narrative_not_found }
36
+ end
37
+ end
38
+
39
+ def add_narrative_theme(narrative_id:, theme:, **)
40
+ result = narrative_engine.add_narrative_theme(narrative_id: narrative_id, theme: theme)
41
+ if result
42
+ Legion::Logging.debug "[narrative_reasoning] theme added theme=#{theme} to #{narrative_id[0..7]}"
43
+ { success: true, narrative_id: narrative_id, theme: theme }
44
+ else
45
+ { success: false, error: :narrative_not_found }
46
+ end
47
+ end
48
+
49
+ def advance_narrative_arc(narrative_id:, **)
50
+ new_stage = narrative_engine.advance_narrative(narrative_id: narrative_id)
51
+ if new_stage
52
+ Legion::Logging.debug "[narrative_reasoning] arc advanced to #{new_stage} for #{narrative_id[0..7]}"
53
+ { success: true, narrative_id: narrative_id, arc_stage: new_stage }
54
+ else
55
+ { success: false, error: :narrative_not_found }
56
+ end
57
+ end
58
+
59
+ def trace_narrative_causes(narrative_id:, **)
60
+ chain = narrative_engine.trace_causal_chain(narrative_id: narrative_id)
61
+ narrative = narrative_engine.get(narrative_id)
62
+ return { success: false, error: :narrative_not_found } unless narrative
63
+
64
+ Legion::Logging.debug "[narrative_reasoning] causal chain length=#{chain.size} for #{narrative_id[0..7]}"
65
+ { success: true, narrative_id: narrative_id, chain: chain, link_count: chain.size }
66
+ end
67
+
68
+ def complete_narratives(**)
69
+ narratives = narrative_engine.complete_narratives
70
+ Legion::Logging.debug "[narrative_reasoning] complete narratives count=#{narratives.size}"
71
+ { success: true, narratives: narratives.map(&:to_h), count: narratives.size }
72
+ end
73
+
74
+ def domain_narratives(domain:, **)
75
+ narratives = narrative_engine.by_domain(domain: domain)
76
+ Legion::Logging.debug "[narrative_reasoning] domain=#{domain} count=#{narratives.size}"
77
+ { success: true, domain: domain, narratives: narratives.map(&:to_h), count: narratives.size }
78
+ end
79
+
80
+ def most_coherent_narratives(limit: 5, **)
81
+ lim = limit.to_i.clamp(1, 50)
82
+ narratives = narrative_engine.most_coherent(limit: lim)
83
+ Legion::Logging.debug "[narrative_reasoning] most_coherent limit=#{lim} returned=#{narratives.size}"
84
+ { success: true, narratives: narratives.map(&:to_h), count: narratives.size }
85
+ end
86
+
87
+ def update_narrative_reasoning(**)
88
+ narrative_engine.decay_all
89
+ count = narrative_engine.count
90
+ Legion::Logging.debug "[narrative_reasoning] decay cycle complete narratives=#{count}"
91
+ { success: true, narratives_updated: count }
92
+ end
93
+
94
+ def narrative_reasoning_stats(**)
95
+ engine = narrative_engine
96
+ complete = engine.complete_narratives.size
97
+ most_coh = engine.most_coherent(limit: 1).first
98
+ Legion::Logging.debug "[narrative_reasoning] stats count=#{engine.count} complete=#{complete}"
99
+ {
100
+ success: true,
101
+ total_narratives: engine.count,
102
+ total_events: engine.total_events,
103
+ complete_narratives: complete,
104
+ top_coherence: most_coh&.coherence,
105
+ top_coherence_label: most_coh&.coherence_label
106
+ }
107
+ end
108
+
109
+ private
110
+
111
+ def narrative_engine
112
+ @narrative_engine ||= Helpers::NarrativeEngine.new
113
+ end
114
+ end
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module NarrativeReasoning
6
+ VERSION = '0.1.0'
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/narrative_reasoning/version'
4
+ require 'legion/extensions/narrative_reasoning/helpers/narrative_event'
5
+ require 'legion/extensions/narrative_reasoning/helpers/narrative'
6
+ require 'legion/extensions/narrative_reasoning/helpers/narrative_engine'
7
+ require 'legion/extensions/narrative_reasoning/runners/narrative_reasoning'
8
+
9
+ module Legion
10
+ module Extensions
11
+ module NarrativeReasoning
12
+ extend Legion::Extensions::Core if Legion::Extensions.const_defined? :Core
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/narrative_reasoning/client'
4
+
5
+ RSpec.describe Legion::Extensions::NarrativeReasoning::Client do
6
+ it 'responds to all runner methods' do
7
+ client = described_class.new
8
+ expect(client).to respond_to(:create_narrative)
9
+ expect(client).to respond_to(:add_narrative_event)
10
+ expect(client).to respond_to(:add_narrative_theme)
11
+ expect(client).to respond_to(:advance_narrative_arc)
12
+ expect(client).to respond_to(:trace_narrative_causes)
13
+ expect(client).to respond_to(:complete_narratives)
14
+ expect(client).to respond_to(:domain_narratives)
15
+ expect(client).to respond_to(:most_coherent_narratives)
16
+ expect(client).to respond_to(:update_narrative_reasoning)
17
+ expect(client).to respond_to(:narrative_reasoning_stats)
18
+ end
19
+ end
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::NarrativeReasoning::Helpers::NarrativeEngine do
4
+ let(:engine) { described_class.new }
5
+
6
+ describe '#create_narrative' do
7
+ it 'creates a new narrative' do
8
+ narrative = engine.create_narrative(title: 'Test Story', domain: 'test')
9
+ expect(narrative).to be_a(Legion::Extensions::NarrativeReasoning::Helpers::Narrative)
10
+ expect(narrative.title).to eq('Test Story')
11
+ expect(narrative.domain).to eq('test')
12
+ end
13
+
14
+ it 'stores the narrative' do
15
+ narrative = engine.create_narrative(title: 'Stored')
16
+ expect(engine.get(narrative.id)).to eq(narrative)
17
+ end
18
+
19
+ it 'increments count' do
20
+ engine.create_narrative(title: 'First')
21
+ engine.create_narrative(title: 'Second')
22
+ expect(engine.count).to eq(2)
23
+ end
24
+ end
25
+
26
+ describe '#add_narrative_event' do
27
+ let(:narrative) { engine.create_narrative(title: 'Events Test') }
28
+
29
+ it 'adds an event to the narrative' do
30
+ event = engine.add_narrative_event(
31
+ narrative_id: narrative.id,
32
+ content: 'Something happened',
33
+ event_type: :action
34
+ )
35
+ expect(event).to be_a(Legion::Extensions::NarrativeReasoning::Helpers::NarrativeEvent)
36
+ expect(narrative.events.size).to eq(1)
37
+ end
38
+
39
+ it 'returns nil for unknown narrative' do
40
+ result = engine.add_narrative_event(
41
+ narrative_id: 'no-such-id',
42
+ content: 'x',
43
+ event_type: :action
44
+ )
45
+ expect(result).to be_nil
46
+ end
47
+
48
+ it 'returns nil for invalid event_type' do
49
+ result = engine.add_narrative_event(
50
+ narrative_id: narrative.id,
51
+ content: 'x',
52
+ event_type: :invalid_type
53
+ )
54
+ expect(result).to be_nil
55
+ end
56
+
57
+ it 'tracks total_events count' do
58
+ engine.add_narrative_event(narrative_id: narrative.id, content: 'a', event_type: :action)
59
+ engine.add_narrative_event(narrative_id: narrative.id, content: 'b', event_type: :conflict)
60
+ expect(engine.total_events).to eq(2)
61
+ end
62
+
63
+ it 'accepts all valid event types' do
64
+ described_class::EVENT_TYPES.each do |type|
65
+ result = engine.add_narrative_event(narrative_id: narrative.id, content: 'x', event_type: type)
66
+ expect(result).not_to be_nil
67
+ end
68
+ end
69
+ end
70
+
71
+ describe '#add_narrative_theme' do
72
+ let(:narrative) { engine.create_narrative(title: 'Theme Test') }
73
+
74
+ it 'adds a theme to the narrative' do
75
+ engine.add_narrative_theme(narrative_id: narrative.id, theme: 'identity')
76
+ expect(narrative.themes).to include('identity')
77
+ end
78
+
79
+ it 'returns nil for unknown narrative' do
80
+ result = engine.add_narrative_theme(narrative_id: 'unknown', theme: 'x')
81
+ expect(result).to be_nil
82
+ end
83
+ end
84
+
85
+ describe '#advance_narrative' do
86
+ let(:narrative) { engine.create_narrative(title: 'Arc Test') }
87
+
88
+ it 'advances arc stage' do
89
+ new_stage = engine.advance_narrative(narrative_id: narrative.id)
90
+ expect(new_stage).to eq(:rising_action)
91
+ end
92
+
93
+ it 'returns nil for unknown narrative' do
94
+ result = engine.advance_narrative(narrative_id: 'bad')
95
+ expect(result).to be_nil
96
+ end
97
+ end
98
+
99
+ describe '#trace_causal_chain' do
100
+ let(:narrative) { engine.create_narrative(title: 'Causal Test') }
101
+
102
+ it 'returns empty array for narrative with no causes' do
103
+ engine.add_narrative_event(narrative_id: narrative.id, content: 'start', event_type: :action)
104
+ chain = engine.trace_causal_chain(narrative_id: narrative.id)
105
+ expect(chain).to be_empty
106
+ end
107
+
108
+ it 'returns causal links when events have causes' do
109
+ evt1 = engine.add_narrative_event(narrative_id: narrative.id, content: 'start', event_type: :action)
110
+ engine.add_narrative_event(
111
+ narrative_id: narrative.id,
112
+ content: 'consequence',
113
+ event_type: :resolution,
114
+ causes: [evt1.id]
115
+ )
116
+ chain = engine.trace_causal_chain(narrative_id: narrative.id)
117
+ expect(chain.size).to eq(1)
118
+ end
119
+
120
+ it 'returns empty array for unknown narrative' do
121
+ expect(engine.trace_causal_chain(narrative_id: 'nope')).to eq([])
122
+ end
123
+ end
124
+
125
+ describe '#complete_narratives' do
126
+ it 'returns narratives at resolution stage' do
127
+ n1 = engine.create_narrative(title: 'Finished')
128
+ 5.times { n1.advance_arc! }
129
+ engine.create_narrative(title: 'In Progress')
130
+ expect(engine.complete_narratives).to include(n1)
131
+ expect(engine.complete_narratives.size).to eq(1)
132
+ end
133
+ end
134
+
135
+ describe '#by_domain' do
136
+ it 'returns narratives matching domain' do
137
+ engine.create_narrative(title: 'A', domain: 'sci-fi')
138
+ engine.create_narrative(title: 'B', domain: 'sci-fi')
139
+ engine.create_narrative(title: 'C', domain: 'mystery')
140
+ expect(engine.by_domain(domain: 'sci-fi').size).to eq(2)
141
+ end
142
+ end
143
+
144
+ describe '#most_coherent' do
145
+ it 'returns narratives sorted by coherence descending' do
146
+ engine.create_narrative(title: 'Low')
147
+ high = engine.create_narrative(title: 'High')
148
+ 5.times { high.add_event(Legion::Extensions::NarrativeReasoning::Helpers::NarrativeEvent.new(content: 'x', event_type: :action)) }
149
+ result = engine.most_coherent(limit: 2)
150
+ expect(result.first).to eq(high)
151
+ end
152
+
153
+ it 'respects the limit' do
154
+ 3.times { |i| engine.create_narrative(title: "N#{i}") }
155
+ expect(engine.most_coherent(limit: 2).size).to be <= 2
156
+ end
157
+ end
158
+
159
+ describe '#decay_all' do
160
+ it 'reduces coherence of all narratives' do
161
+ n = engine.create_narrative(title: 'Decay Test')
162
+ before = n.coherence
163
+ engine.decay_all
164
+ expect(n.coherence).to be < before
165
+ end
166
+ end
167
+
168
+ describe '#to_h' do
169
+ it 'includes count and narratives array' do
170
+ engine.create_narrative(title: 'One')
171
+ h = engine.to_h
172
+ expect(h[:count]).to eq(1)
173
+ expect(h[:narratives]).to be_an(Array)
174
+ end
175
+ end
176
+
177
+ describe 'EVENT_TYPES constant' do
178
+ it 'includes the five required types' do
179
+ expect(described_class::EVENT_TYPES).to include(:action, :discovery, :conflict, :resolution, :revelation)
180
+ end
181
+ end
182
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::NarrativeReasoning::Helpers::NarrativeEvent do
4
+ let(:event) do
5
+ described_class.new(
6
+ content: 'The detective discovers the clue',
7
+ event_type: :discovery,
8
+ characters: %w[detective suspect],
9
+ causes: [],
10
+ domain: 'mystery'
11
+ )
12
+ end
13
+
14
+ describe '#initialize' do
15
+ it 'assigns a uuid id' do
16
+ expect(event.id).to match(/\A[0-9a-f-]{36}\z/)
17
+ end
18
+
19
+ it 'stores content' do
20
+ expect(event.content).to eq('The detective discovers the clue')
21
+ end
22
+
23
+ it 'stores event_type' do
24
+ expect(event.event_type).to eq(:discovery)
25
+ end
26
+
27
+ it 'stores characters as array' do
28
+ expect(event.characters).to contain_exactly('detective', 'suspect')
29
+ end
30
+
31
+ it 'stores causes' do
32
+ expect(event.causes).to eq([])
33
+ end
34
+
35
+ it 'stores domain' do
36
+ expect(event.domain).to eq('mystery')
37
+ end
38
+
39
+ it 'sets timestamp to now' do
40
+ expect(event.timestamp).to be_a(Time)
41
+ end
42
+
43
+ it 'accepts explicit id' do
44
+ custom = described_class.new(content: 'x', event_type: :action, id: 'custom-id')
45
+ expect(custom.id).to eq('custom-id')
46
+ end
47
+ end
48
+
49
+ describe '#to_h' do
50
+ it 'returns a hash with all fields' do
51
+ h = event.to_h
52
+ expect(h[:id]).to eq(event.id)
53
+ expect(h[:content]).to eq('The detective discovers the clue')
54
+ expect(h[:event_type]).to eq(:discovery)
55
+ expect(h[:characters]).to eq(%w[detective suspect])
56
+ expect(h[:causes]).to eq([])
57
+ expect(h[:domain]).to eq('mystery')
58
+ expect(h[:timestamp]).to be_a(Time)
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,168 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::NarrativeReasoning::Helpers::Narrative do
4
+ let(:narrative) { described_class.new(title: 'The Journey', domain: 'adventure') }
5
+
6
+ let(:event1) do
7
+ Legion::Extensions::NarrativeReasoning::Helpers::NarrativeEvent.new(
8
+ content: 'Hero leaves home',
9
+ event_type: :action,
10
+ characters: ['hero']
11
+ )
12
+ end
13
+
14
+ let(:event2) do
15
+ Legion::Extensions::NarrativeReasoning::Helpers::NarrativeEvent.new(
16
+ content: 'Hero discovers map',
17
+ event_type: :discovery,
18
+ characters: ['hero'],
19
+ causes: [event1.id]
20
+ )
21
+ end
22
+
23
+ describe '#initialize' do
24
+ it 'assigns id, title, domain' do
25
+ expect(narrative.id).to match(/\A[0-9a-f-]{36}\z/)
26
+ expect(narrative.title).to eq('The Journey')
27
+ expect(narrative.domain).to eq('adventure')
28
+ end
29
+
30
+ it 'starts at beginning arc stage' do
31
+ expect(narrative.arc_stage).to eq(:beginning)
32
+ end
33
+
34
+ it 'starts with default coherence' do
35
+ expect(narrative.coherence).to eq(described_class::DEFAULT_COHERENCE)
36
+ end
37
+
38
+ it 'starts with empty events, characters, themes' do
39
+ expect(narrative.events).to be_empty
40
+ expect(narrative.characters).to be_empty
41
+ expect(narrative.themes).to be_empty
42
+ end
43
+ end
44
+
45
+ describe '#add_event' do
46
+ it 'appends the event' do
47
+ narrative.add_event(event1)
48
+ expect(narrative.events).to include(event1)
49
+ end
50
+
51
+ it 'collects unique characters' do
52
+ narrative.add_event(event1)
53
+ narrative.add_event(event2)
54
+ expect(narrative.characters).to contain_exactly('hero')
55
+ end
56
+
57
+ it 'boosts coherence on each event' do
58
+ initial = narrative.coherence
59
+ narrative.add_event(event1)
60
+ expect(narrative.coherence).to be > initial
61
+ end
62
+
63
+ it 'does not exceed coherence ceiling' do
64
+ 15.times { narrative.add_event(event1) }
65
+ expect(narrative.coherence).to be <= described_class::COHERENCE_CEILING
66
+ end
67
+
68
+ it 'updates last_updated_at' do
69
+ before = narrative.last_updated_at
70
+ sleep(0.01)
71
+ narrative.add_event(event1)
72
+ expect(narrative.last_updated_at).to be >= before
73
+ end
74
+ end
75
+
76
+ describe '#add_theme' do
77
+ it 'adds a theme' do
78
+ narrative.add_theme('redemption')
79
+ expect(narrative.themes).to include('redemption')
80
+ end
81
+
82
+ it 'does not add duplicate themes' do
83
+ narrative.add_theme('redemption')
84
+ narrative.add_theme('redemption')
85
+ expect(narrative.themes.count('redemption')).to eq(1)
86
+ end
87
+ end
88
+
89
+ describe '#advance_arc!' do
90
+ it 'advances to next arc stage' do
91
+ narrative.advance_arc!
92
+ expect(narrative.arc_stage).to eq(:rising_action)
93
+ end
94
+
95
+ it 'does not advance past resolution' do
96
+ 5.times { narrative.advance_arc! }
97
+ expect(narrative.arc_stage).to eq(:resolution)
98
+ narrative.advance_arc!
99
+ expect(narrative.arc_stage).to eq(:resolution)
100
+ end
101
+ end
102
+
103
+ describe '#causal_chain' do
104
+ it 'returns empty with no causes' do
105
+ narrative.add_event(event1)
106
+ expect(narrative.causal_chain).to be_empty
107
+ end
108
+
109
+ it 'returns cause->effect links' do
110
+ narrative.add_event(event1)
111
+ narrative.add_event(event2)
112
+ chain = narrative.causal_chain
113
+ expect(chain.size).to eq(1)
114
+ expect(chain.first[:cause][:id]).to eq(event1.id)
115
+ expect(chain.first[:effect][:id]).to eq(event2.id)
116
+ end
117
+ end
118
+
119
+ describe '#coherence_label' do
120
+ it 'returns :developing for default coherence' do
121
+ expect(narrative.coherence_label).to eq(:developing)
122
+ end
123
+
124
+ it 'returns :compelling for high coherence' do
125
+ 10.times { narrative.add_event(event1) }
126
+ expect(narrative.coherence_label).to eq(:compelling)
127
+ end
128
+ end
129
+
130
+ describe '#complete?' do
131
+ it 'returns false at start' do
132
+ expect(narrative.complete?).to be false
133
+ end
134
+
135
+ it 'returns true at resolution' do
136
+ 5.times { narrative.advance_arc! }
137
+ expect(narrative.complete?).to be true
138
+ end
139
+ end
140
+
141
+ describe '#decay_coherence' do
142
+ it 'reduces coherence by DECAY_RATE' do
143
+ before = narrative.coherence
144
+ narrative.decay_coherence
145
+ expect(narrative.coherence).to be_within(0.001).of(before - described_class::DECAY_RATE)
146
+ end
147
+
148
+ it 'does not go below coherence floor' do
149
+ 200.times { narrative.decay_coherence }
150
+ expect(narrative.coherence).to eq(described_class::COHERENCE_FLOOR)
151
+ end
152
+ end
153
+
154
+ describe '#to_h' do
155
+ it 'includes all expected keys' do
156
+ h = narrative.to_h
157
+ expect(h).to include(:id, :title, :domain, :events, :characters, :themes,
158
+ :arc_stage, :coherence, :coherence_label, :complete,
159
+ :created_at, :last_updated_at)
160
+ end
161
+ end
162
+
163
+ describe 'ARC_STAGES constant' do
164
+ it 'contains the five expected stages in order' do
165
+ expect(described_class::ARC_STAGES).to eq(%i[beginning rising_action climax falling_action resolution])
166
+ end
167
+ end
168
+ end
@@ -0,0 +1,174 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/narrative_reasoning/client'
4
+
5
+ RSpec.describe Legion::Extensions::NarrativeReasoning::Runners::NarrativeReasoning do
6
+ let(:client) { Legion::Extensions::NarrativeReasoning::Client.new }
7
+
8
+ describe '#create_narrative' do
9
+ it 'returns success with narrative_id and title' do
10
+ result = client.create_narrative(title: 'My Story', domain: 'fantasy')
11
+ expect(result[:success]).to be true
12
+ expect(result[:narrative_id]).to match(/\A[0-9a-f-]{36}\z/)
13
+ expect(result[:title]).to eq('My Story')
14
+ expect(result[:arc_stage]).to eq(:beginning)
15
+ end
16
+ end
17
+
18
+ describe '#add_narrative_event' do
19
+ let(:narrative_id) { client.create_narrative(title: 'Event Test')[:narrative_id] }
20
+
21
+ it 'adds an event and returns event_id' do
22
+ result = client.add_narrative_event(
23
+ narrative_id: narrative_id,
24
+ content: 'Hero acts',
25
+ event_type: :action,
26
+ characters: ['hero']
27
+ )
28
+ expect(result[:success]).to be true
29
+ expect(result[:event_id]).to match(/\A[0-9a-f-]{36}\z/)
30
+ end
31
+
32
+ it 'returns error for unknown narrative' do
33
+ result = client.add_narrative_event(narrative_id: 'bad', content: 'x', event_type: :action)
34
+ expect(result[:success]).to be false
35
+ expect(result[:error]).to eq(:narrative_not_found)
36
+ end
37
+
38
+ it 'returns error for invalid event_type' do
39
+ result = client.add_narrative_event(narrative_id: narrative_id, content: 'x', event_type: :bogus)
40
+ expect(result[:success]).to be false
41
+ expect(result[:error]).to eq(:invalid_event_type)
42
+ end
43
+ end
44
+
45
+ describe '#add_narrative_theme' do
46
+ let(:narrative_id) { client.create_narrative(title: 'Theme Test')[:narrative_id] }
47
+
48
+ it 'adds a theme successfully' do
49
+ result = client.add_narrative_theme(narrative_id: narrative_id, theme: 'courage')
50
+ expect(result[:success]).to be true
51
+ expect(result[:theme]).to eq('courage')
52
+ end
53
+
54
+ it 'returns error for unknown narrative' do
55
+ result = client.add_narrative_theme(narrative_id: 'nope', theme: 'x')
56
+ expect(result[:success]).to be false
57
+ expect(result[:error]).to eq(:narrative_not_found)
58
+ end
59
+ end
60
+
61
+ describe '#advance_narrative_arc' do
62
+ let(:narrative_id) { client.create_narrative(title: 'Arc Test')[:narrative_id] }
63
+
64
+ it 'advances to next arc stage' do
65
+ result = client.advance_narrative_arc(narrative_id: narrative_id)
66
+ expect(result[:success]).to be true
67
+ expect(result[:arc_stage]).to eq(:rising_action)
68
+ end
69
+
70
+ it 'returns error for unknown narrative' do
71
+ result = client.advance_narrative_arc(narrative_id: 'bad')
72
+ expect(result[:success]).to be false
73
+ expect(result[:error]).to eq(:narrative_not_found)
74
+ end
75
+ end
76
+
77
+ describe '#trace_narrative_causes' do
78
+ let(:narrative_id) { client.create_narrative(title: 'Causal Test')[:narrative_id] }
79
+
80
+ it 'returns empty chain with no causes' do
81
+ client.add_narrative_event(narrative_id: narrative_id, content: 'start', event_type: :action)
82
+ result = client.trace_narrative_causes(narrative_id: narrative_id)
83
+ expect(result[:success]).to be true
84
+ expect(result[:chain]).to eq([])
85
+ expect(result[:link_count]).to eq(0)
86
+ end
87
+
88
+ it 'returns causal links' do
89
+ evt = client.add_narrative_event(narrative_id: narrative_id, content: 'cause', event_type: :action)
90
+ client.add_narrative_event(
91
+ narrative_id: narrative_id,
92
+ content: 'effect',
93
+ event_type: :resolution,
94
+ causes: [evt[:event_id]]
95
+ )
96
+ result = client.trace_narrative_causes(narrative_id: narrative_id)
97
+ expect(result[:link_count]).to eq(1)
98
+ end
99
+
100
+ it 'returns error for unknown narrative' do
101
+ result = client.trace_narrative_causes(narrative_id: 'nope')
102
+ expect(result[:success]).to be false
103
+ end
104
+ end
105
+
106
+ describe '#complete_narratives' do
107
+ it 'returns empty list when none are complete' do
108
+ client.create_narrative(title: 'Incomplete')
109
+ result = client.complete_narratives
110
+ expect(result[:success]).to be true
111
+ expect(result[:count]).to eq(0)
112
+ end
113
+
114
+ it 'includes narratives that reached resolution' do
115
+ nid = client.create_narrative(title: 'Done')[:narrative_id]
116
+ 5.times { client.advance_narrative_arc(narrative_id: nid) }
117
+ result = client.complete_narratives
118
+ expect(result[:count]).to eq(1)
119
+ end
120
+ end
121
+
122
+ describe '#domain_narratives' do
123
+ it 'filters by domain' do
124
+ client.create_narrative(title: 'A', domain: 'sci-fi')
125
+ client.create_narrative(title: 'B', domain: 'sci-fi')
126
+ client.create_narrative(title: 'C', domain: 'horror')
127
+ result = client.domain_narratives(domain: 'sci-fi')
128
+ expect(result[:success]).to be true
129
+ expect(result[:count]).to eq(2)
130
+ end
131
+ end
132
+
133
+ describe '#most_coherent_narratives' do
134
+ it 'returns up to limit narratives' do
135
+ 3.times { |i| client.create_narrative(title: "N#{i}") }
136
+ result = client.most_coherent_narratives(limit: 2)
137
+ expect(result[:success]).to be true
138
+ expect(result[:narratives].size).to be <= 2
139
+ end
140
+
141
+ it 'clamps limit to minimum 1' do
142
+ client.create_narrative(title: 'Solo')
143
+ result = client.most_coherent_narratives(limit: 0)
144
+ expect(result[:narratives].size).to be >= 1
145
+ end
146
+ end
147
+
148
+ describe '#update_narrative_reasoning' do
149
+ it 'returns success with count of updated narratives' do
150
+ client.create_narrative(title: 'A')
151
+ client.create_narrative(title: 'B')
152
+ result = client.update_narrative_reasoning
153
+ expect(result[:success]).to be true
154
+ expect(result[:narratives_updated]).to eq(2)
155
+ end
156
+ end
157
+
158
+ describe '#narrative_reasoning_stats' do
159
+ it 'returns stats hash' do
160
+ client.create_narrative(title: 'Stats Test')
161
+ result = client.narrative_reasoning_stats
162
+ expect(result[:success]).to be true
163
+ expect(result[:total_narratives]).to eq(1)
164
+ expect(result[:total_events]).to be_a(Integer)
165
+ expect(result[:complete_narratives]).to be_a(Integer)
166
+ end
167
+
168
+ it 'reports top_coherence_label' do
169
+ client.create_narrative(title: 'Some Narrative')
170
+ result = client.narrative_reasoning_stats
171
+ expect(%i[compelling coherent developing fragmented incoherent]).to include(result[:top_coherence_label])
172
+ end
173
+ end
174
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/setup'
4
+
5
+ module Legion
6
+ module Logging
7
+ def self.debug(_msg); end
8
+ def self.info(_msg); end
9
+ def self.warn(_msg); end
10
+ def self.error(_msg); end
11
+ end
12
+ end
13
+
14
+ require 'legion/extensions/narrative_reasoning'
15
+
16
+ RSpec.configure do |config|
17
+ config.example_status_persistence_file_path = '.rspec_status'
18
+ config.disable_monkey_patching!
19
+ config.expect_with(:rspec) { |c| c.syntax = :expect }
20
+ end
metadata ADDED
@@ -0,0 +1,76 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: lex-narrative-reasoning
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Esity
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: legion-gaia
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :development
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
26
+ description: Narrative reasoning engine for brain-modeled agentic AI — story structure,
27
+ causal chains, arc progression
28
+ email:
29
+ - matthewdiverson@gmail.com
30
+ executables: []
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - Gemfile
35
+ - lex-narrative-reasoning.gemspec
36
+ - lib/legion/extensions/narrative_reasoning.rb
37
+ - lib/legion/extensions/narrative_reasoning/client.rb
38
+ - lib/legion/extensions/narrative_reasoning/helpers/narrative.rb
39
+ - lib/legion/extensions/narrative_reasoning/helpers/narrative_engine.rb
40
+ - lib/legion/extensions/narrative_reasoning/helpers/narrative_event.rb
41
+ - lib/legion/extensions/narrative_reasoning/runners/narrative_reasoning.rb
42
+ - lib/legion/extensions/narrative_reasoning/version.rb
43
+ - spec/legion/extensions/narrative_reasoning/client_spec.rb
44
+ - spec/legion/extensions/narrative_reasoning/helpers/narrative_engine_spec.rb
45
+ - spec/legion/extensions/narrative_reasoning/helpers/narrative_event_spec.rb
46
+ - spec/legion/extensions/narrative_reasoning/helpers/narrative_spec.rb
47
+ - spec/legion/extensions/narrative_reasoning/runners/narrative_reasoning_spec.rb
48
+ - spec/spec_helper.rb
49
+ homepage: https://github.com/LegionIO/lex-narrative-reasoning
50
+ licenses:
51
+ - MIT
52
+ metadata:
53
+ homepage_uri: https://github.com/LegionIO/lex-narrative-reasoning
54
+ source_code_uri: https://github.com/LegionIO/lex-narrative-reasoning
55
+ documentation_uri: https://github.com/LegionIO/lex-narrative-reasoning
56
+ changelog_uri: https://github.com/LegionIO/lex-narrative-reasoning
57
+ bug_tracker_uri: https://github.com/LegionIO/lex-narrative-reasoning/issues
58
+ rubygems_mfa_required: 'true'
59
+ rdoc_options: []
60
+ require_paths:
61
+ - lib
62
+ required_ruby_version: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - ">="
65
+ - !ruby/object:Gem::Version
66
+ version: '3.4'
67
+ required_rubygems_version: !ruby/object:Gem::Requirement
68
+ requirements:
69
+ - - ">="
70
+ - !ruby/object:Gem::Version
71
+ version: '0'
72
+ requirements: []
73
+ rubygems_version: 3.6.9
74
+ specification_version: 4
75
+ summary: LEX Narrative Reasoning
76
+ test_files: []