lex-causal-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-causal-reasoning.gemspec +29 -0
- data/lib/legion/extensions/causal_reasoning/client.rb +20 -0
- data/lib/legion/extensions/causal_reasoning/helpers/causal_edge.rb +97 -0
- data/lib/legion/extensions/causal_reasoning/helpers/causal_graph.rb +180 -0
- data/lib/legion/extensions/causal_reasoning/helpers/constants.rb +36 -0
- data/lib/legion/extensions/causal_reasoning/runners/causal_reasoning.rb +107 -0
- data/lib/legion/extensions/causal_reasoning/version.rb +9 -0
- data/lib/legion/extensions/causal_reasoning.rb +15 -0
- data/spec/legion/extensions/causal_reasoning/client_spec.rb +35 -0
- data/spec/legion/extensions/causal_reasoning/helpers/causal_edge_spec.rb +158 -0
- data/spec/legion/extensions/causal_reasoning/helpers/causal_graph_spec.rb +259 -0
- data/spec/legion/extensions/causal_reasoning/runners/causal_reasoning_spec.rb +161 -0
- data/spec/spec_helper.rb +24 -0
- metadata +75 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 88afa7864d6dde283f8596ad1e51ad4a3bc78979213b93f17586cb4e9b7bece6
|
|
4
|
+
data.tar.gz: 6c079e57fb21b832e38292d99ad9ba065e34f1118cdfa45f96ecf035af316fce
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 71ca379df8257700e646032095624246f273063884224e0f74718bf13b61eaa6aecaa499a07f88b799997f4706a8e1280f2aac672efe56fcb54141b2dbdcee8f
|
|
7
|
+
data.tar.gz: d5596c7ea7ef37f4f3c83aaf5787a353cc0b75272b83ce80a85c535cceb6d520d2926b312931baa875b89d2578ff38b6d6e2f817f06e4ede3b7c7e7247a0a3a7
|
data/Gemfile
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'lib/legion/extensions/causal_reasoning/version'
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = 'lex-causal-reasoning'
|
|
7
|
+
spec.version = Legion::Extensions::CausalReasoning::VERSION
|
|
8
|
+
spec.authors = ['Esity']
|
|
9
|
+
spec.email = ['matthewdiverson@gmail.com']
|
|
10
|
+
|
|
11
|
+
spec.summary = 'LEX Causal Reasoning'
|
|
12
|
+
spec.description = 'Causal inference engine for brain-modeled agentic AI — do-calculus and causal graphs'
|
|
13
|
+
spec.homepage = 'https://github.com/LegionIO/lex-causal-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-causal-reasoning'
|
|
19
|
+
spec.metadata['documentation_uri'] = 'https://github.com/LegionIO/lex-causal-reasoning'
|
|
20
|
+
spec.metadata['changelog_uri'] = 'https://github.com/LegionIO/lex-causal-reasoning'
|
|
21
|
+
spec.metadata['bug_tracker_uri'] = 'https://github.com/LegionIO/lex-causal-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-causal-reasoning.gemspec Gemfile]
|
|
26
|
+
end
|
|
27
|
+
spec.require_paths = ['lib']
|
|
28
|
+
spec.add_development_dependency 'legion-gaia'
|
|
29
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/causal_reasoning/helpers/constants'
|
|
4
|
+
require 'legion/extensions/causal_reasoning/helpers/causal_edge'
|
|
5
|
+
require 'legion/extensions/causal_reasoning/helpers/causal_graph'
|
|
6
|
+
require 'legion/extensions/causal_reasoning/runners/causal_reasoning'
|
|
7
|
+
|
|
8
|
+
module Legion
|
|
9
|
+
module Extensions
|
|
10
|
+
module CausalReasoning
|
|
11
|
+
class Client
|
|
12
|
+
include Runners::CausalReasoning
|
|
13
|
+
|
|
14
|
+
def initialize(graph: nil, **)
|
|
15
|
+
@graph = graph
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module Extensions
|
|
7
|
+
module CausalReasoning
|
|
8
|
+
module Helpers
|
|
9
|
+
class CausalEdge
|
|
10
|
+
include Constants
|
|
11
|
+
|
|
12
|
+
attr_reader :id, :cause, :effect, :edge_type, :domain, :evidence_count, :created_at, :updated_at, :strength
|
|
13
|
+
|
|
14
|
+
def initialize(cause:, effect:, edge_type:, domain: :general, strength: Constants::DEFAULT_STRENGTH)
|
|
15
|
+
@id = SecureRandom.uuid
|
|
16
|
+
@cause = cause
|
|
17
|
+
@effect = effect
|
|
18
|
+
@edge_type = edge_type
|
|
19
|
+
@domain = domain
|
|
20
|
+
@strength = strength.clamp(Constants::STRENGTH_FLOOR, Constants::STRENGTH_CEILING)
|
|
21
|
+
@evidence_count = 0
|
|
22
|
+
@created_at = Time.now.utc
|
|
23
|
+
@updated_at = Time.now.utc
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def add_evidence
|
|
27
|
+
@evidence_count += 1
|
|
28
|
+
@strength = (@strength + Constants::REINFORCEMENT_RATE).clamp(
|
|
29
|
+
Constants::STRENGTH_FLOOR, Constants::STRENGTH_CEILING
|
|
30
|
+
)
|
|
31
|
+
@updated_at = Time.now.utc
|
|
32
|
+
self
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def remove_evidence
|
|
36
|
+
@evidence_count = [@evidence_count - 1, 0].max
|
|
37
|
+
@strength = (@strength - Constants::REINFORCEMENT_RATE).clamp(
|
|
38
|
+
Constants::STRENGTH_FLOOR, Constants::STRENGTH_CEILING
|
|
39
|
+
)
|
|
40
|
+
@updated_at = Time.now.utc
|
|
41
|
+
self
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def reinforce(amount: Constants::REINFORCEMENT_RATE)
|
|
45
|
+
@strength = (@strength + amount).clamp(
|
|
46
|
+
Constants::STRENGTH_FLOOR, Constants::STRENGTH_CEILING
|
|
47
|
+
)
|
|
48
|
+
@updated_at = Time.now.utc
|
|
49
|
+
self
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def weaken(amount: Constants::REINFORCEMENT_RATE)
|
|
53
|
+
@strength = (@strength - amount).clamp(
|
|
54
|
+
Constants::STRENGTH_FLOOR, Constants::STRENGTH_CEILING
|
|
55
|
+
)
|
|
56
|
+
@updated_at = Time.now.utc
|
|
57
|
+
self
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def decay
|
|
61
|
+
@strength = (@strength - Constants::DECAY_RATE).clamp(
|
|
62
|
+
Constants::STRENGTH_FLOOR, Constants::STRENGTH_CEILING
|
|
63
|
+
)
|
|
64
|
+
@updated_at = Time.now.utc
|
|
65
|
+
self
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def confident?
|
|
69
|
+
@strength >= Constants::CAUSAL_THRESHOLD &&
|
|
70
|
+
@evidence_count >= Constants::EVIDENCE_THRESHOLD
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def confidence_label
|
|
74
|
+
match = Constants::CONFIDENCE_LABELS.find { |range, _| range.cover?(@strength) }
|
|
75
|
+
match ? match[1] : :speculative
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def to_h
|
|
79
|
+
{
|
|
80
|
+
id: @id,
|
|
81
|
+
cause: @cause,
|
|
82
|
+
effect: @effect,
|
|
83
|
+
edge_type: @edge_type,
|
|
84
|
+
domain: @domain,
|
|
85
|
+
strength: @strength,
|
|
86
|
+
evidence_count: @evidence_count,
|
|
87
|
+
confident: confident?,
|
|
88
|
+
label: confidence_label,
|
|
89
|
+
created_at: @created_at,
|
|
90
|
+
updated_at: @updated_at
|
|
91
|
+
}
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module CausalReasoning
|
|
6
|
+
module Helpers
|
|
7
|
+
class CausalGraph
|
|
8
|
+
def initialize
|
|
9
|
+
@variables = {}
|
|
10
|
+
@edges = {}
|
|
11
|
+
@edge_index = {}
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def add_variable(name:, domain: :general)
|
|
15
|
+
return nil if @variables.key?(name)
|
|
16
|
+
return nil if @variables.size >= Constants::MAX_VARIABLES
|
|
17
|
+
|
|
18
|
+
@variables[name] = { name: name, domain: domain, added_at: Time.now.utc }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def variable_exists?(name)
|
|
22
|
+
@variables.key?(name)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def add_edge(cause:, effect:, edge_type:, domain: :general, strength: Constants::DEFAULT_STRENGTH)
|
|
26
|
+
return nil if @edges.size >= Constants::MAX_EDGES
|
|
27
|
+
return nil unless Constants::EDGE_TYPES.include?(edge_type)
|
|
28
|
+
|
|
29
|
+
add_variable(name: cause, domain: domain)
|
|
30
|
+
add_variable(name: effect, domain: domain)
|
|
31
|
+
|
|
32
|
+
edge = CausalEdge.new(cause: cause, effect: effect,
|
|
33
|
+
edge_type: edge_type, domain: domain, strength: strength)
|
|
34
|
+
@edges[edge.id] = edge
|
|
35
|
+
@edge_index[cause] ||= []
|
|
36
|
+
@edge_index[cause] << edge.id
|
|
37
|
+
edge
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def remove_edge(edge_id:)
|
|
41
|
+
edge = @edges.delete(edge_id)
|
|
42
|
+
return nil unless edge
|
|
43
|
+
|
|
44
|
+
@edge_index[edge.cause]&.delete(edge_id)
|
|
45
|
+
edge
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def causes_of(variable:)
|
|
49
|
+
@edges.values.select { |e| e.effect == variable }
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def effects_of(variable:)
|
|
53
|
+
ids = @edge_index.fetch(variable, [])
|
|
54
|
+
ids.filter_map { |id| @edges[id] }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def causal_chain(from:, to:, max_depth: 5)
|
|
58
|
+
return [] if from == to
|
|
59
|
+
|
|
60
|
+
queue = [[from, [from]]]
|
|
61
|
+
visited = { from => true }
|
|
62
|
+
paths = []
|
|
63
|
+
|
|
64
|
+
until queue.empty?
|
|
65
|
+
current, path = queue.shift
|
|
66
|
+
next if path.size > max_depth + 1
|
|
67
|
+
|
|
68
|
+
effects_of(variable: current).each do |edge|
|
|
69
|
+
neighbor = edge.effect
|
|
70
|
+
new_path = path + [neighbor]
|
|
71
|
+
|
|
72
|
+
if neighbor == to
|
|
73
|
+
paths << new_path
|
|
74
|
+
next
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
next if visited[neighbor]
|
|
78
|
+
|
|
79
|
+
visited[neighbor] = true
|
|
80
|
+
queue << [neighbor, new_path]
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
paths
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def intervene(variable:, value:)
|
|
88
|
+
downstream = []
|
|
89
|
+
queue = [variable]
|
|
90
|
+
visited = { variable => true }
|
|
91
|
+
|
|
92
|
+
until queue.empty?
|
|
93
|
+
current = queue.shift
|
|
94
|
+
effects_of(variable: current).each do |edge|
|
|
95
|
+
neighbor = edge.effect
|
|
96
|
+
downstream << { variable: neighbor, via_edge: edge.id, edge_type: edge.edge_type }
|
|
97
|
+
next if visited[neighbor]
|
|
98
|
+
|
|
99
|
+
visited[neighbor] = true
|
|
100
|
+
queue << neighbor
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
{ intervention: variable, value: value, downstream_effects: downstream }
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def observe(variable:, value:, evidence:)
|
|
108
|
+
affected = causes_of(variable: variable) + effects_of(variable: variable)
|
|
109
|
+
affected.each { |edge| edge.add_evidence if evidence }
|
|
110
|
+
{ variable: variable, value: value, edges_updated: affected.size }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def confounders(var_a:, var_b:)
|
|
114
|
+
ancestors_a = ancestors_of(var_a)
|
|
115
|
+
ancestors_b = ancestors_of(var_b)
|
|
116
|
+
common = ancestors_a & ancestors_b
|
|
117
|
+
common.reject { |v| v == var_a || v == var_b }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def add_evidence(edge_id:)
|
|
121
|
+
edge = @edges[edge_id]
|
|
122
|
+
return nil unless edge
|
|
123
|
+
|
|
124
|
+
edge.add_evidence
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def remove_evidence(edge_id:)
|
|
128
|
+
edge = @edges[edge_id]
|
|
129
|
+
return nil unless edge
|
|
130
|
+
|
|
131
|
+
edge.remove_evidence
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def confident_edges
|
|
135
|
+
@edges.values.select(&:confident?)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def by_domain(domain:)
|
|
139
|
+
@edges.values.select { |e| e.domain == domain }
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def by_type(type:)
|
|
143
|
+
@edges.values.select { |e| e.edge_type == type }
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def decay_all
|
|
147
|
+
@edges.each_value(&:decay)
|
|
148
|
+
@edges.size
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
def prune_weak
|
|
152
|
+
weak_ids = @edges.select { |_, e| e.strength <= Constants::STRENGTH_FLOOR }.keys
|
|
153
|
+
weak_ids.each { |id| remove_edge(edge_id: id) }
|
|
154
|
+
weak_ids.size
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def to_h
|
|
158
|
+
{
|
|
159
|
+
variables: @variables.size,
|
|
160
|
+
edges: @edges.size,
|
|
161
|
+
confident_edges: confident_edges.size,
|
|
162
|
+
edge_types: Constants::EDGE_TYPES.to_h { |t| [t, by_type(type: t).size] }
|
|
163
|
+
}
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
private
|
|
167
|
+
|
|
168
|
+
def ancestors_of(variable, visited = {})
|
|
169
|
+
return [] if visited[variable]
|
|
170
|
+
|
|
171
|
+
visited[variable] = true
|
|
172
|
+
direct_causes = causes_of(variable: variable).map(&:cause)
|
|
173
|
+
indirect = direct_causes.flat_map { |c| ancestors_of(c, visited) }
|
|
174
|
+
(direct_causes + indirect).uniq
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module CausalReasoning
|
|
6
|
+
module Helpers
|
|
7
|
+
module Constants
|
|
8
|
+
MAX_VARIABLES = 200
|
|
9
|
+
MAX_EDGES = 500
|
|
10
|
+
MAX_HISTORY = 300
|
|
11
|
+
|
|
12
|
+
DEFAULT_STRENGTH = 0.5
|
|
13
|
+
STRENGTH_FLOOR = 0.05
|
|
14
|
+
STRENGTH_CEILING = 0.95
|
|
15
|
+
|
|
16
|
+
EVIDENCE_THRESHOLD = 3
|
|
17
|
+
CAUSAL_THRESHOLD = 0.6
|
|
18
|
+
|
|
19
|
+
REINFORCEMENT_RATE = 0.1
|
|
20
|
+
DECAY_RATE = 0.01
|
|
21
|
+
|
|
22
|
+
EDGE_TYPES = %i[causes prevents enables inhibits modulates].freeze
|
|
23
|
+
INFERENCE_TYPES = %i[observation intervention counterfactual].freeze
|
|
24
|
+
|
|
25
|
+
CONFIDENCE_LABELS = {
|
|
26
|
+
(0.8..) => :strong,
|
|
27
|
+
(0.6...0.8) => :moderate,
|
|
28
|
+
(0.4...0.6) => :weak,
|
|
29
|
+
(0.2...0.4) => :tentative,
|
|
30
|
+
(..0.2) => :speculative
|
|
31
|
+
}.freeze
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module CausalReasoning
|
|
6
|
+
module Runners
|
|
7
|
+
module CausalReasoning
|
|
8
|
+
include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers) &&
|
|
9
|
+
Legion::Extensions::Helpers.const_defined?(:Lex)
|
|
10
|
+
|
|
11
|
+
def add_causal_variable(name:, domain: :general, **)
|
|
12
|
+
if graph.variable_exists?(name)
|
|
13
|
+
Legion::Logging.warn "[causal] add_variable duplicate: name=#{name}"
|
|
14
|
+
return { success: false, reason: :limit_or_duplicate, name: name }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
variable = graph.add_variable(name: name, domain: domain)
|
|
18
|
+
if variable
|
|
19
|
+
Legion::Logging.debug "[causal] add_variable: name=#{name} domain=#{domain}"
|
|
20
|
+
{ success: true, variable: variable }
|
|
21
|
+
else
|
|
22
|
+
Legion::Logging.warn "[causal] add_variable failed (limit): name=#{name}"
|
|
23
|
+
{ success: false, reason: :limit_or_duplicate, name: name }
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def add_causal_edge(cause:, effect:, edge_type:, domain: :general,
|
|
28
|
+
strength: Helpers::Constants::DEFAULT_STRENGTH, **)
|
|
29
|
+
edge = graph.add_edge(cause: cause, effect: effect,
|
|
30
|
+
edge_type: edge_type, domain: domain, strength: strength)
|
|
31
|
+
if edge
|
|
32
|
+
str = edge.strength.round(2)
|
|
33
|
+
Legion::Logging.debug "[causal] add_edge: #{cause}->#{effect} type=#{edge_type} str=#{str}"
|
|
34
|
+
{ success: true, edge: edge.to_h }
|
|
35
|
+
else
|
|
36
|
+
Legion::Logging.warn "[causal] add_edge failed: cause=#{cause} effect=#{effect} type=#{edge_type}"
|
|
37
|
+
{ success: false, reason: :limit_or_invalid_type, cause: cause, effect: effect }
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def find_causes(variable:, **)
|
|
42
|
+
edges = graph.causes_of(variable: variable)
|
|
43
|
+
Legion::Logging.debug "[causal] find_causes: variable=#{variable} count=#{edges.size}"
|
|
44
|
+
{ success: true, variable: variable, causes: edges.map(&:to_h), count: edges.size }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def find_effects(variable:, **)
|
|
48
|
+
edges = graph.effects_of(variable: variable)
|
|
49
|
+
Legion::Logging.debug "[causal] find_effects: variable=#{variable} count=#{edges.size}"
|
|
50
|
+
{ success: true, variable: variable, effects: edges.map(&:to_h), count: edges.size }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def trace_causal_chain(from:, to:, max_depth: 5, **)
|
|
54
|
+
paths = graph.causal_chain(from: from, to: to, max_depth: max_depth)
|
|
55
|
+
Legion::Logging.debug "[causal] trace_chain: from=#{from} to=#{to} paths=#{paths.size}"
|
|
56
|
+
{ success: true, from: from, to: to, paths: paths, path_count: paths.size }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def causal_intervention(variable:, value:, **)
|
|
60
|
+
result = graph.intervene(variable: variable, value: value)
|
|
61
|
+
count = result[:downstream_effects].size
|
|
62
|
+
Legion::Logging.info "[causal] intervention: do(#{variable}=#{value}) downstream=#{count}"
|
|
63
|
+
{ success: true }.merge(result)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def find_confounders(var_a:, var_b:, **)
|
|
67
|
+
common = graph.confounders(var_a: var_a, var_b: var_b)
|
|
68
|
+
Legion::Logging.debug "[causal] confounders: #{var_a} <-> #{var_b} count=#{common.size}"
|
|
69
|
+
{ success: true, var_a: var_a, var_b: var_b, confounders: common, count: common.size }
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def add_causal_evidence(edge_id:, **)
|
|
73
|
+
edge = graph.add_evidence(edge_id: edge_id)
|
|
74
|
+
if edge
|
|
75
|
+
cnt = edge.evidence_count
|
|
76
|
+
str = edge.strength.round(2)
|
|
77
|
+
Legion::Logging.debug "[causal] add_evidence: edge=#{edge_id} count=#{cnt} strength=#{str}"
|
|
78
|
+
{ success: true, edge_id: edge_id, evidence_count: edge.evidence_count, strength: edge.strength }
|
|
79
|
+
else
|
|
80
|
+
Legion::Logging.warn "[causal] add_evidence failed: edge=#{edge_id} not found"
|
|
81
|
+
{ success: false, reason: :edge_not_found, edge_id: edge_id }
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def update_causal_reasoning(**)
|
|
86
|
+
decayed = graph.decay_all
|
|
87
|
+
pruned = graph.prune_weak
|
|
88
|
+
Legion::Logging.debug "[causal] update: decayed=#{decayed} pruned=#{pruned}"
|
|
89
|
+
{ success: true, decayed: decayed, pruned: pruned }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def causal_reasoning_stats(**)
|
|
93
|
+
stats = graph.to_h
|
|
94
|
+
Legion::Logging.debug "[causal] stats: #{stats.inspect}"
|
|
95
|
+
{ success: true }.merge(stats)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
def graph
|
|
101
|
+
@graph ||= Helpers::CausalGraph.new
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/causal_reasoning/version'
|
|
4
|
+
require 'legion/extensions/causal_reasoning/helpers/constants'
|
|
5
|
+
require 'legion/extensions/causal_reasoning/helpers/causal_edge'
|
|
6
|
+
require 'legion/extensions/causal_reasoning/helpers/causal_graph'
|
|
7
|
+
require 'legion/extensions/causal_reasoning/runners/causal_reasoning'
|
|
8
|
+
|
|
9
|
+
module Legion
|
|
10
|
+
module Extensions
|
|
11
|
+
module CausalReasoning
|
|
12
|
+
extend Legion::Extensions::Core if Legion::Extensions.const_defined? :Core
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/causal_reasoning/client'
|
|
4
|
+
|
|
5
|
+
RSpec.describe Legion::Extensions::CausalReasoning::Client do
|
|
6
|
+
let(:client) { described_class.new }
|
|
7
|
+
|
|
8
|
+
it 'responds to all runner methods' do
|
|
9
|
+
expect(client).to respond_to(:add_causal_variable)
|
|
10
|
+
expect(client).to respond_to(:add_causal_edge)
|
|
11
|
+
expect(client).to respond_to(:find_causes)
|
|
12
|
+
expect(client).to respond_to(:find_effects)
|
|
13
|
+
expect(client).to respond_to(:trace_causal_chain)
|
|
14
|
+
expect(client).to respond_to(:causal_intervention)
|
|
15
|
+
expect(client).to respond_to(:find_confounders)
|
|
16
|
+
expect(client).to respond_to(:add_causal_evidence)
|
|
17
|
+
expect(client).to respond_to(:update_causal_reasoning)
|
|
18
|
+
expect(client).to respond_to(:causal_reasoning_stats)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
it 'accepts an injected graph' do
|
|
22
|
+
injected_graph = Legion::Extensions::CausalReasoning::Helpers::CausalGraph.new
|
|
23
|
+
injected_graph.add_edge(cause: :a, effect: :b, edge_type: :causes)
|
|
24
|
+
client_with_graph = described_class.new(graph: injected_graph)
|
|
25
|
+
result = client_with_graph.causal_reasoning_stats
|
|
26
|
+
expect(result[:edges]).to eq(1)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
it 'maintains isolated state between instances' do
|
|
30
|
+
c1 = described_class.new
|
|
31
|
+
c2 = described_class.new
|
|
32
|
+
c1.add_causal_edge(cause: :a, effect: :b, edge_type: :causes)
|
|
33
|
+
expect(c2.causal_reasoning_stats[:edges]).to eq(0)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/causal_reasoning/client'
|
|
4
|
+
|
|
5
|
+
RSpec.describe Legion::Extensions::CausalReasoning::Helpers::CausalEdge do
|
|
6
|
+
subject(:edge) do
|
|
7
|
+
described_class.new(cause: :rain, effect: :wet_grass, edge_type: :causes, domain: :weather)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
describe '#initialize' do
|
|
11
|
+
it 'sets cause and effect' do
|
|
12
|
+
expect(edge.cause).to eq(:rain)
|
|
13
|
+
expect(edge.effect).to eq(:wet_grass)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
it 'sets edge_type and domain' do
|
|
17
|
+
expect(edge.edge_type).to eq(:causes)
|
|
18
|
+
expect(edge.domain).to eq(:weather)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
it 'clamps strength to STRENGTH_FLOOR..STRENGTH_CEILING' do
|
|
22
|
+
over = described_class.new(cause: :a, effect: :b, edge_type: :causes, strength: 2.0)
|
|
23
|
+
under = described_class.new(cause: :a, effect: :b, edge_type: :causes, strength: 0.0)
|
|
24
|
+
expect(over.strength).to eq(Legion::Extensions::CausalReasoning::Helpers::Constants::STRENGTH_CEILING)
|
|
25
|
+
expect(under.strength).to eq(Legion::Extensions::CausalReasoning::Helpers::Constants::STRENGTH_FLOOR)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
it 'starts with evidence_count of zero' do
|
|
29
|
+
expect(edge.evidence_count).to eq(0)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
it 'assigns a unique id' do
|
|
33
|
+
other = described_class.new(cause: :rain, effect: :wet_grass, edge_type: :causes)
|
|
34
|
+
expect(edge.id).not_to eq(other.id)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
describe '#add_evidence' do
|
|
39
|
+
it 'increments evidence_count' do
|
|
40
|
+
edge.add_evidence
|
|
41
|
+
expect(edge.evidence_count).to eq(1)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
it 'increases strength' do
|
|
45
|
+
before = edge.strength
|
|
46
|
+
edge.add_evidence
|
|
47
|
+
expect(edge.strength).to be > before
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
it 'returns self' do
|
|
51
|
+
expect(edge.add_evidence).to be(edge)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
it 'does not exceed STRENGTH_CEILING' do
|
|
55
|
+
100.times { edge.add_evidence }
|
|
56
|
+
expect(edge.strength).to eq(Legion::Extensions::CausalReasoning::Helpers::Constants::STRENGTH_CEILING)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
describe '#remove_evidence' do
|
|
61
|
+
it 'decrements evidence_count but not below zero' do
|
|
62
|
+
edge.remove_evidence
|
|
63
|
+
expect(edge.evidence_count).to eq(0)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
it 'decreases strength' do
|
|
67
|
+
before = edge.strength
|
|
68
|
+
edge.remove_evidence
|
|
69
|
+
expect(edge.strength).to be < before
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
it 'does not go below STRENGTH_FLOOR' do
|
|
73
|
+
100.times { edge.remove_evidence }
|
|
74
|
+
expect(edge.strength).to eq(Legion::Extensions::CausalReasoning::Helpers::Constants::STRENGTH_FLOOR)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
describe '#reinforce' do
|
|
79
|
+
it 'increases strength by given amount' do
|
|
80
|
+
before = edge.strength
|
|
81
|
+
edge.reinforce(amount: 0.2)
|
|
82
|
+
expect(edge.strength).to be_within(0.001).of(before + 0.2)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
it 'clamps at STRENGTH_CEILING' do
|
|
86
|
+
edge.reinforce(amount: 1.0)
|
|
87
|
+
expect(edge.strength).to eq(Legion::Extensions::CausalReasoning::Helpers::Constants::STRENGTH_CEILING)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
describe '#weaken' do
|
|
92
|
+
it 'decreases strength by given amount' do
|
|
93
|
+
before = edge.strength
|
|
94
|
+
edge.weaken(amount: 0.1)
|
|
95
|
+
expect(edge.strength).to be_within(0.001).of(before - 0.1)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
it 'clamps at STRENGTH_FLOOR' do
|
|
99
|
+
edge.weaken(amount: 1.0)
|
|
100
|
+
expect(edge.strength).to eq(Legion::Extensions::CausalReasoning::Helpers::Constants::STRENGTH_FLOOR)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
describe '#decay' do
|
|
105
|
+
it 'reduces strength by DECAY_RATE' do
|
|
106
|
+
before = edge.strength
|
|
107
|
+
edge.decay
|
|
108
|
+
expect(edge.strength).to be_within(0.001).of(before - Legion::Extensions::CausalReasoning::Helpers::Constants::DECAY_RATE)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
it 'does not go below STRENGTH_FLOOR' do
|
|
112
|
+
100.times { edge.decay }
|
|
113
|
+
expect(edge.strength).to eq(Legion::Extensions::CausalReasoning::Helpers::Constants::STRENGTH_FLOOR)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
describe '#confident?' do
|
|
118
|
+
it 'returns false when evidence_count is below EVIDENCE_THRESHOLD' do
|
|
119
|
+
expect(edge.confident?).to be false
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
it 'returns true when both strength and evidence_count meet thresholds' do
|
|
123
|
+
3.times { edge.add_evidence }
|
|
124
|
+
edge.reinforce(amount: 0.2)
|
|
125
|
+
expect(edge.confident?).to be true
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
describe '#confidence_label' do
|
|
130
|
+
it 'returns :speculative for low strength' do
|
|
131
|
+
edge.weaken(amount: 1.0)
|
|
132
|
+
expect(edge.confidence_label).to eq(:speculative)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
it 'returns :moderate for strength around 0.7' do
|
|
136
|
+
strong_edge = described_class.new(cause: :a, effect: :b, edge_type: :causes, strength: 0.7)
|
|
137
|
+
expect(strong_edge.confidence_label).to eq(:moderate)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
it 'returns :strong for strength >= 0.8' do
|
|
141
|
+
strong_edge = described_class.new(cause: :a, effect: :b, edge_type: :causes, strength: 0.9)
|
|
142
|
+
expect(strong_edge.confidence_label).to eq(:strong)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
describe '#to_h' do
|
|
147
|
+
it 'includes all key fields' do
|
|
148
|
+
hash = edge.to_h
|
|
149
|
+
expect(hash).to include(:id, :cause, :effect, :edge_type, :domain,
|
|
150
|
+
:strength, :evidence_count, :confident, :label,
|
|
151
|
+
:created_at, :updated_at)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
it 'reflects confident? state' do
|
|
155
|
+
expect(edge.to_h[:confident]).to be false
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/causal_reasoning/client'
|
|
4
|
+
|
|
5
|
+
RSpec.describe Legion::Extensions::CausalReasoning::Helpers::CausalGraph do
|
|
6
|
+
subject(:graph) { described_class.new }
|
|
7
|
+
|
|
8
|
+
let(:const) { Legion::Extensions::CausalReasoning::Helpers::Constants }
|
|
9
|
+
|
|
10
|
+
def add_rain_wet(grph = graph)
|
|
11
|
+
grph.add_edge(cause: :rain, effect: :wet_grass, edge_type: :causes, domain: :weather)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
describe '#add_variable' do
|
|
15
|
+
it 'registers a new variable and returns it' do
|
|
16
|
+
var = graph.add_variable(name: :temperature, domain: :weather)
|
|
17
|
+
expect(var[:name]).to eq(:temperature)
|
|
18
|
+
expect(var[:domain]).to eq(:weather)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
it 'returns nil for a duplicate variable name' do
|
|
22
|
+
graph.add_variable(name: :temperature, domain: :weather)
|
|
23
|
+
result = graph.add_variable(name: :temperature, domain: :weather)
|
|
24
|
+
expect(result).to be_nil
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
describe '#add_edge' do
|
|
29
|
+
it 'creates and returns a CausalEdge' do
|
|
30
|
+
edge = add_rain_wet
|
|
31
|
+
expect(edge).to be_a(Legion::Extensions::CausalReasoning::Helpers::CausalEdge)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
it 'auto-registers both cause and effect as variables' do
|
|
35
|
+
add_rain_wet
|
|
36
|
+
expect(graph.to_h[:variables]).to eq(2)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
it 'returns nil for unknown edge_type' do
|
|
40
|
+
result = graph.add_edge(cause: :a, effect: :b, edge_type: :unknown_type)
|
|
41
|
+
expect(result).to be_nil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
it 'accepts all valid EDGE_TYPES' do
|
|
45
|
+
const::EDGE_TYPES.each_with_index do |type, idx|
|
|
46
|
+
result = graph.add_edge(cause: :"cause_#{idx}", effect: :"effect_#{idx}", edge_type: type)
|
|
47
|
+
expect(result).not_to be_nil
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
describe '#remove_edge' do
|
|
53
|
+
it 'removes an existing edge and returns it' do
|
|
54
|
+
edge = add_rain_wet
|
|
55
|
+
removed = graph.remove_edge(edge_id: edge.id)
|
|
56
|
+
expect(removed.id).to eq(edge.id)
|
|
57
|
+
expect(graph.to_h[:edges]).to eq(0)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
it 'returns nil for unknown edge_id' do
|
|
61
|
+
expect(graph.remove_edge(edge_id: 'nonexistent')).to be_nil
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
describe '#causes_of' do
|
|
66
|
+
it 'returns edges where the variable is the effect' do
|
|
67
|
+
add_rain_wet
|
|
68
|
+
causes = graph.causes_of(variable: :wet_grass)
|
|
69
|
+
expect(causes.size).to eq(1)
|
|
70
|
+
expect(causes.first.cause).to eq(:rain)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
it 'returns empty array for variable with no causes' do
|
|
74
|
+
expect(graph.causes_of(variable: :rain)).to be_empty
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
describe '#effects_of' do
|
|
79
|
+
it 'returns edges where the variable is the cause' do
|
|
80
|
+
add_rain_wet
|
|
81
|
+
effects = graph.effects_of(variable: :rain)
|
|
82
|
+
expect(effects.size).to eq(1)
|
|
83
|
+
expect(effects.first.effect).to eq(:wet_grass)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
it 'returns empty array for unknown variable' do
|
|
87
|
+
expect(graph.effects_of(variable: :unknown)).to be_empty
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
describe '#causal_chain' do
|
|
92
|
+
before do
|
|
93
|
+
graph.add_edge(cause: :rain, effect: :wet_grass, edge_type: :causes)
|
|
94
|
+
graph.add_edge(cause: :wet_grass, effect: :slippery_path, edge_type: :causes)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
it 'finds a direct path between connected variables' do
|
|
98
|
+
paths = graph.causal_chain(from: :rain, to: :slippery_path)
|
|
99
|
+
expect(paths).not_to be_empty
|
|
100
|
+
expect(paths.first).to eq(%i[rain wet_grass slippery_path])
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
it 'returns empty array when no path exists' do
|
|
104
|
+
paths = graph.causal_chain(from: :slippery_path, to: :rain)
|
|
105
|
+
expect(paths).to be_empty
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
it 'returns empty array when from == to' do
|
|
109
|
+
paths = graph.causal_chain(from: :rain, to: :rain)
|
|
110
|
+
expect(paths).to be_empty
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
it 'respects max_depth' do
|
|
114
|
+
graph.add_edge(cause: :slippery_path, effect: :injury, edge_type: :causes)
|
|
115
|
+
graph.add_edge(cause: :injury, effect: :hospital_visit, edge_type: :causes)
|
|
116
|
+
paths = graph.causal_chain(from: :rain, to: :hospital_visit, max_depth: 2)
|
|
117
|
+
expect(paths).to be_empty
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
describe '#intervene' do
|
|
122
|
+
before do
|
|
123
|
+
graph.add_edge(cause: :smoking, effect: :cancer, edge_type: :causes)
|
|
124
|
+
graph.add_edge(cause: :cancer, effect: :death, edge_type: :causes)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
it 'returns the intervention variable and value' do
|
|
128
|
+
result = graph.intervene(variable: :smoking, value: true)
|
|
129
|
+
expect(result[:intervention]).to eq(:smoking)
|
|
130
|
+
expect(result[:value]).to be true
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
it 'lists all downstream effects' do
|
|
134
|
+
result = graph.intervene(variable: :smoking, value: true)
|
|
135
|
+
variables = result[:downstream_effects].map { |e| e[:variable] }
|
|
136
|
+
expect(variables).to include(:cancer, :death)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
it 'returns empty downstream for leaf variable' do
|
|
140
|
+
result = graph.intervene(variable: :death, value: true)
|
|
141
|
+
expect(result[:downstream_effects]).to be_empty
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
describe '#observe' do
|
|
146
|
+
it 'updates edges involving the variable when evidence is true' do
|
|
147
|
+
edge = add_rain_wet
|
|
148
|
+
before_count = edge.evidence_count
|
|
149
|
+
graph.observe(variable: :rain, value: true, evidence: true)
|
|
150
|
+
expect(edge.evidence_count).to be > before_count
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
it 'returns the variable, value, and count of edges updated' do
|
|
154
|
+
add_rain_wet
|
|
155
|
+
result = graph.observe(variable: :rain, value: true, evidence: true)
|
|
156
|
+
expect(result[:variable]).to eq(:rain)
|
|
157
|
+
expect(result[:edges_updated]).to eq(1)
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
describe '#confounders' do
|
|
162
|
+
it 'finds common ancestors of two variables' do
|
|
163
|
+
graph.add_edge(cause: :weather, effect: :rain, edge_type: :causes)
|
|
164
|
+
graph.add_edge(cause: :weather, effect: :cold, edge_type: :causes)
|
|
165
|
+
common = graph.confounders(var_a: :rain, var_b: :cold)
|
|
166
|
+
expect(common).to include(:weather)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
it 'returns empty array when no confounders exist' do
|
|
170
|
+
graph.add_edge(cause: :a, effect: :b, edge_type: :causes)
|
|
171
|
+
graph.add_edge(cause: :c, effect: :d, edge_type: :causes)
|
|
172
|
+
expect(graph.confounders(var_a: :b, var_b: :d)).to be_empty
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
describe '#add_evidence and #remove_evidence' do
|
|
177
|
+
it 'delegates add_evidence to the edge' do
|
|
178
|
+
edge = add_rain_wet
|
|
179
|
+
graph.add_evidence(edge_id: edge.id)
|
|
180
|
+
expect(edge.evidence_count).to eq(1)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
it 'returns nil for unknown edge_id in add_evidence' do
|
|
184
|
+
expect(graph.add_evidence(edge_id: 'bad_id')).to be_nil
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
it 'delegates remove_evidence to the edge' do
|
|
188
|
+
edge = add_rain_wet
|
|
189
|
+
edge.add_evidence
|
|
190
|
+
graph.remove_evidence(edge_id: edge.id)
|
|
191
|
+
expect(edge.evidence_count).to eq(0)
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
describe '#confident_edges' do
|
|
196
|
+
it 'returns only edges meeting confidence threshold' do
|
|
197
|
+
edge = add_rain_wet
|
|
198
|
+
3.times { edge.add_evidence }
|
|
199
|
+
edge.reinforce(amount: 0.2)
|
|
200
|
+
expect(graph.confident_edges).to include(edge)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
it 'excludes edges below threshold' do
|
|
204
|
+
add_rain_wet
|
|
205
|
+
expect(graph.confident_edges).to be_empty
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
describe '#by_domain and #by_type' do
|
|
210
|
+
before do
|
|
211
|
+
graph.add_edge(cause: :rain, effect: :wet_grass, edge_type: :causes, domain: :weather)
|
|
212
|
+
graph.add_edge(cause: :stress, effect: :illness, edge_type: :causes, domain: :health)
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
it 'filters edges by domain' do
|
|
216
|
+
expect(graph.by_domain(domain: :weather).size).to eq(1)
|
|
217
|
+
expect(graph.by_domain(domain: :health).size).to eq(1)
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
it 'filters edges by type' do
|
|
221
|
+
expect(graph.by_type(type: :causes).size).to eq(2)
|
|
222
|
+
expect(graph.by_type(type: :prevents).size).to eq(0)
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
describe '#decay_all' do
|
|
227
|
+
it 'decays all edges and returns total count' do
|
|
228
|
+
add_rain_wet
|
|
229
|
+
result = graph.decay_all
|
|
230
|
+
expect(result).to eq(1)
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
describe '#prune_weak' do
|
|
235
|
+
it 'removes edges at STRENGTH_FLOOR and returns count' do
|
|
236
|
+
edge = add_rain_wet
|
|
237
|
+
100.times { edge.weaken }
|
|
238
|
+
pruned = graph.prune_weak
|
|
239
|
+
expect(pruned).to eq(1)
|
|
240
|
+
expect(graph.to_h[:edges]).to eq(0)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
it 'returns 0 when no weak edges exist' do
|
|
244
|
+
add_rain_wet
|
|
245
|
+
expect(graph.prune_weak).to eq(0)
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
describe '#to_h' do
|
|
250
|
+
it 'returns stats hash with variables, edges, confident_edges, edge_types' do
|
|
251
|
+
add_rain_wet
|
|
252
|
+
stats = graph.to_h
|
|
253
|
+
expect(stats[:variables]).to eq(2)
|
|
254
|
+
expect(stats[:edges]).to eq(1)
|
|
255
|
+
expect(stats[:edge_types]).to be_a(Hash)
|
|
256
|
+
expect(stats[:edge_types][:causes]).to eq(1)
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
end
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/causal_reasoning/client'
|
|
4
|
+
|
|
5
|
+
RSpec.describe Legion::Extensions::CausalReasoning::Runners::CausalReasoning do
|
|
6
|
+
let(:client) { Legion::Extensions::CausalReasoning::Client.new }
|
|
7
|
+
|
|
8
|
+
describe '#add_causal_variable' do
|
|
9
|
+
it 'returns success: true and the variable hash' do
|
|
10
|
+
result = client.add_causal_variable(name: :temperature, domain: :weather)
|
|
11
|
+
expect(result[:success]).to be true
|
|
12
|
+
expect(result[:variable][:name]).to eq(:temperature)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
it 'returns success: false for duplicate variable' do
|
|
16
|
+
client.add_causal_variable(name: :temperature, domain: :weather)
|
|
17
|
+
result = client.add_causal_variable(name: :temperature, domain: :weather)
|
|
18
|
+
expect(result[:success]).to be false
|
|
19
|
+
expect(result[:reason]).to eq(:limit_or_duplicate)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
describe '#add_causal_edge' do
|
|
24
|
+
it 'returns success: true and an edge hash' do
|
|
25
|
+
result = client.add_causal_edge(cause: :rain, effect: :wet_grass, edge_type: :causes)
|
|
26
|
+
expect(result[:success]).to be true
|
|
27
|
+
expect(result[:edge][:cause]).to eq(:rain)
|
|
28
|
+
expect(result[:edge][:effect]).to eq(:wet_grass)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
it 'returns success: false for invalid edge_type' do
|
|
32
|
+
result = client.add_causal_edge(cause: :a, effect: :b, edge_type: :unknown)
|
|
33
|
+
expect(result[:success]).to be false
|
|
34
|
+
expect(result[:reason]).to eq(:limit_or_invalid_type)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
it 'uses given strength' do
|
|
38
|
+
result = client.add_causal_edge(cause: :a, effect: :b, edge_type: :causes, strength: 0.8)
|
|
39
|
+
expect(result[:edge][:strength]).to be_within(0.001).of(0.8)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
describe '#find_causes' do
|
|
44
|
+
it 'returns causes of a variable' do
|
|
45
|
+
client.add_causal_edge(cause: :rain, effect: :wet_grass, edge_type: :causes)
|
|
46
|
+
result = client.find_causes(variable: :wet_grass)
|
|
47
|
+
expect(result[:success]).to be true
|
|
48
|
+
expect(result[:count]).to eq(1)
|
|
49
|
+
expect(result[:causes].first[:cause]).to eq(:rain)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
it 'returns empty causes for variable with no incoming edges' do
|
|
53
|
+
client.add_causal_edge(cause: :rain, effect: :wet_grass, edge_type: :causes)
|
|
54
|
+
result = client.find_causes(variable: :rain)
|
|
55
|
+
expect(result[:count]).to eq(0)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
describe '#find_effects' do
|
|
60
|
+
it 'returns effects of a variable' do
|
|
61
|
+
client.add_causal_edge(cause: :rain, effect: :wet_grass, edge_type: :causes)
|
|
62
|
+
result = client.find_effects(variable: :rain)
|
|
63
|
+
expect(result[:success]).to be true
|
|
64
|
+
expect(result[:count]).to eq(1)
|
|
65
|
+
expect(result[:effects].first[:effect]).to eq(:wet_grass)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
it 'returns empty effects for leaf variable' do
|
|
69
|
+
client.add_causal_edge(cause: :rain, effect: :wet_grass, edge_type: :causes)
|
|
70
|
+
result = client.find_effects(variable: :wet_grass)
|
|
71
|
+
expect(result[:count]).to eq(0)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
describe '#trace_causal_chain' do
|
|
76
|
+
before do
|
|
77
|
+
client.add_causal_edge(cause: :rain, effect: :wet_grass, edge_type: :causes)
|
|
78
|
+
client.add_causal_edge(cause: :wet_grass, effect: :slippery, edge_type: :causes)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
it 'finds a multi-hop causal path' do
|
|
82
|
+
result = client.trace_causal_chain(from: :rain, to: :slippery)
|
|
83
|
+
expect(result[:success]).to be true
|
|
84
|
+
expect(result[:path_count]).to be >= 1
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
it 'returns zero paths when no connection' do
|
|
88
|
+
result = client.trace_causal_chain(from: :slippery, to: :rain)
|
|
89
|
+
expect(result[:path_count]).to eq(0)
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
describe '#causal_intervention' do
|
|
94
|
+
before do
|
|
95
|
+
client.add_causal_edge(cause: :smoking, effect: :cancer, edge_type: :causes)
|
|
96
|
+
client.add_causal_edge(cause: :cancer, effect: :treatment, edge_type: :causes)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
it 'returns success: true and lists downstream effects' do
|
|
100
|
+
result = client.causal_intervention(variable: :smoking, value: true)
|
|
101
|
+
expect(result[:success]).to be true
|
|
102
|
+
expect(result[:intervention]).to eq(:smoking)
|
|
103
|
+
downstream_vars = result[:downstream_effects].map { |e| e[:variable] }
|
|
104
|
+
expect(downstream_vars).to include(:cancer, :treatment)
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
describe '#find_confounders' do
|
|
109
|
+
before do
|
|
110
|
+
client.add_causal_edge(cause: :weather, effect: :rain, edge_type: :causes)
|
|
111
|
+
client.add_causal_edge(cause: :weather, effect: :cold, edge_type: :causes)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
it 'identifies common ancestors as confounders' do
|
|
115
|
+
result = client.find_confounders(var_a: :rain, var_b: :cold)
|
|
116
|
+
expect(result[:success]).to be true
|
|
117
|
+
expect(result[:confounders]).to include(:weather)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
it 'returns empty confounders for unrelated variables' do
|
|
121
|
+
result = client.find_confounders(var_a: :rain, var_b: :unrelated)
|
|
122
|
+
expect(result[:count]).to eq(0)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
describe '#add_causal_evidence' do
|
|
127
|
+
it 'increments evidence on an existing edge' do
|
|
128
|
+
edge_result = client.add_causal_edge(cause: :rain, effect: :wet_grass, edge_type: :causes)
|
|
129
|
+
edge_id = edge_result[:edge][:id]
|
|
130
|
+
result = client.add_causal_evidence(edge_id: edge_id)
|
|
131
|
+
expect(result[:success]).to be true
|
|
132
|
+
expect(result[:evidence_count]).to eq(1)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
it 'returns success: false for unknown edge_id' do
|
|
136
|
+
result = client.add_causal_evidence(edge_id: 'nonexistent-uuid')
|
|
137
|
+
expect(result[:success]).to be false
|
|
138
|
+
expect(result[:reason]).to eq(:edge_not_found)
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
describe '#update_causal_reasoning' do
|
|
143
|
+
it 'returns success: true with decayed and pruned counts' do
|
|
144
|
+
client.add_causal_edge(cause: :rain, effect: :wet_grass, edge_type: :causes)
|
|
145
|
+
result = client.update_causal_reasoning
|
|
146
|
+
expect(result[:success]).to be true
|
|
147
|
+
expect(result[:decayed]).to eq(1)
|
|
148
|
+
expect(result[:pruned]).to eq(0)
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
describe '#causal_reasoning_stats' do
|
|
153
|
+
it 'returns stats including variables and edges counts' do
|
|
154
|
+
client.add_causal_edge(cause: :rain, effect: :wet_grass, edge_type: :causes)
|
|
155
|
+
result = client.causal_reasoning_stats
|
|
156
|
+
expect(result[:success]).to be true
|
|
157
|
+
expect(result[:variables]).to eq(2)
|
|
158
|
+
expect(result[:edges]).to eq(1)
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
data/spec/spec_helper.rb
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
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
|
+
|
|
13
|
+
module Extensions
|
|
14
|
+
module Helpers; end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
require 'legion/extensions/causal_reasoning'
|
|
19
|
+
|
|
20
|
+
RSpec.configure do |config|
|
|
21
|
+
config.example_status_persistence_file_path = '.rspec_status'
|
|
22
|
+
config.disable_monkey_patching!
|
|
23
|
+
config.expect_with(:rspec) { |c| c.syntax = :expect }
|
|
24
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: lex-causal-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: Causal inference engine for brain-modeled agentic AI — do-calculus and
|
|
27
|
+
causal graphs
|
|
28
|
+
email:
|
|
29
|
+
- matthewdiverson@gmail.com
|
|
30
|
+
executables: []
|
|
31
|
+
extensions: []
|
|
32
|
+
extra_rdoc_files: []
|
|
33
|
+
files:
|
|
34
|
+
- Gemfile
|
|
35
|
+
- lex-causal-reasoning.gemspec
|
|
36
|
+
- lib/legion/extensions/causal_reasoning.rb
|
|
37
|
+
- lib/legion/extensions/causal_reasoning/client.rb
|
|
38
|
+
- lib/legion/extensions/causal_reasoning/helpers/causal_edge.rb
|
|
39
|
+
- lib/legion/extensions/causal_reasoning/helpers/causal_graph.rb
|
|
40
|
+
- lib/legion/extensions/causal_reasoning/helpers/constants.rb
|
|
41
|
+
- lib/legion/extensions/causal_reasoning/runners/causal_reasoning.rb
|
|
42
|
+
- lib/legion/extensions/causal_reasoning/version.rb
|
|
43
|
+
- spec/legion/extensions/causal_reasoning/client_spec.rb
|
|
44
|
+
- spec/legion/extensions/causal_reasoning/helpers/causal_edge_spec.rb
|
|
45
|
+
- spec/legion/extensions/causal_reasoning/helpers/causal_graph_spec.rb
|
|
46
|
+
- spec/legion/extensions/causal_reasoning/runners/causal_reasoning_spec.rb
|
|
47
|
+
- spec/spec_helper.rb
|
|
48
|
+
homepage: https://github.com/LegionIO/lex-causal-reasoning
|
|
49
|
+
licenses:
|
|
50
|
+
- MIT
|
|
51
|
+
metadata:
|
|
52
|
+
homepage_uri: https://github.com/LegionIO/lex-causal-reasoning
|
|
53
|
+
source_code_uri: https://github.com/LegionIO/lex-causal-reasoning
|
|
54
|
+
documentation_uri: https://github.com/LegionIO/lex-causal-reasoning
|
|
55
|
+
changelog_uri: https://github.com/LegionIO/lex-causal-reasoning
|
|
56
|
+
bug_tracker_uri: https://github.com/LegionIO/lex-causal-reasoning/issues
|
|
57
|
+
rubygems_mfa_required: 'true'
|
|
58
|
+
rdoc_options: []
|
|
59
|
+
require_paths:
|
|
60
|
+
- lib
|
|
61
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
62
|
+
requirements:
|
|
63
|
+
- - ">="
|
|
64
|
+
- !ruby/object:Gem::Version
|
|
65
|
+
version: '3.4'
|
|
66
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
67
|
+
requirements:
|
|
68
|
+
- - ">="
|
|
69
|
+
- !ruby/object:Gem::Version
|
|
70
|
+
version: '0'
|
|
71
|
+
requirements: []
|
|
72
|
+
rubygems_version: 3.6.9
|
|
73
|
+
specification_version: 4
|
|
74
|
+
summary: LEX Causal Reasoning
|
|
75
|
+
test_files: []
|