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 +7 -0
- data/Gemfile +11 -0
- data/lex-narrative-reasoning.gemspec +29 -0
- data/lib/legion/extensions/narrative_reasoning/client.rb +24 -0
- data/lib/legion/extensions/narrative_reasoning/helpers/narrative.rb +119 -0
- data/lib/legion/extensions/narrative_reasoning/helpers/narrative_engine.rb +118 -0
- data/lib/legion/extensions/narrative_reasoning/helpers/narrative_event.rb +37 -0
- data/lib/legion/extensions/narrative_reasoning/runners/narrative_reasoning.rb +118 -0
- data/lib/legion/extensions/narrative_reasoning/version.rb +9 -0
- data/lib/legion/extensions/narrative_reasoning.rb +15 -0
- data/spec/legion/extensions/narrative_reasoning/client_spec.rb +19 -0
- data/spec/legion/extensions/narrative_reasoning/helpers/narrative_engine_spec.rb +182 -0
- data/spec/legion/extensions/narrative_reasoning/helpers/narrative_event_spec.rb +61 -0
- data/spec/legion/extensions/narrative_reasoning/helpers/narrative_spec.rb +168 -0
- data/spec/legion/extensions/narrative_reasoning/runners/narrative_reasoning_spec.rb +174 -0
- data/spec/spec_helper.rb +20 -0
- metadata +76 -0
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,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,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
|
data/spec/spec_helper.rb
ADDED
|
@@ -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: []
|