lex-abductive-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-abductive-reasoning.gemspec +29 -0
- data/lib/legion/extensions/abductive_reasoning/client.rb +21 -0
- data/lib/legion/extensions/abductive_reasoning/helpers/abduction_engine.rb +160 -0
- data/lib/legion/extensions/abductive_reasoning/helpers/constants.rb +35 -0
- data/lib/legion/extensions/abductive_reasoning/helpers/hypothesis.rb +102 -0
- data/lib/legion/extensions/abductive_reasoning/helpers/observation.rb +35 -0
- data/lib/legion/extensions/abductive_reasoning/runners/abductive_reasoning.rb +125 -0
- data/lib/legion/extensions/abductive_reasoning/version.rb +9 -0
- data/lib/legion/extensions/abductive_reasoning.rb +16 -0
- data/spec/legion/extensions/abductive_reasoning/client_spec.rb +25 -0
- data/spec/legion/extensions/abductive_reasoning/runners/abductive_reasoning_spec.rb +349 -0
- data/spec/spec_helper.rb +20 -0
- metadata +74 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 81b8af5d21f6a677bd9d1a625f01a62f27b6371653f48ead364306a9c783868a
|
|
4
|
+
data.tar.gz: ee959cfaec090545d8c412609c44fda9d38ae7f8e6c87a290973bfc421e41442
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 6e4f124201ff7740d8e4ec8d264447cf95236df6ffbbca2e66cb91e31d6654e05395eb07ecf8cf48a5cd75c74254fabc405d5e0d675b169b7b88fd5c81e18f2d
|
|
7
|
+
data.tar.gz: d91e013c47d85b926c4e2585975ab8671e7c5f9a041ec1c26e39c116965288972a0422790eed6b034ca1ce0ecdfcf79d0a3d4dd93102a3a815f06da5c399e846
|
data/Gemfile
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'lib/legion/extensions/abductive_reasoning/version'
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = 'lex-abductive-reasoning'
|
|
7
|
+
spec.version = Legion::Extensions::AbductiveReasoning::VERSION
|
|
8
|
+
spec.authors = ['Esity']
|
|
9
|
+
spec.email = ['matthewdiverson@gmail.com']
|
|
10
|
+
|
|
11
|
+
spec.summary = 'LEX Abductive Reasoning'
|
|
12
|
+
spec.description = 'Peirce abductive reasoning engine — inference to the best explanation for brain-modeled agentic AI'
|
|
13
|
+
spec.homepage = 'https://github.com/LegionIO/lex-abductive-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-abductive-reasoning'
|
|
19
|
+
spec.metadata['documentation_uri'] = 'https://github.com/LegionIO/lex-abductive-reasoning'
|
|
20
|
+
spec.metadata['changelog_uri'] = 'https://github.com/LegionIO/lex-abductive-reasoning'
|
|
21
|
+
spec.metadata['bug_tracker_uri'] = 'https://github.com/LegionIO/lex-abductive-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-abductive-reasoning.gemspec Gemfile]
|
|
26
|
+
end
|
|
27
|
+
spec.require_paths = ['lib']
|
|
28
|
+
spec.add_development_dependency 'legion-gaia'
|
|
29
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/abductive_reasoning/helpers/constants'
|
|
4
|
+
require 'legion/extensions/abductive_reasoning/helpers/observation'
|
|
5
|
+
require 'legion/extensions/abductive_reasoning/helpers/hypothesis'
|
|
6
|
+
require 'legion/extensions/abductive_reasoning/helpers/abduction_engine'
|
|
7
|
+
require 'legion/extensions/abductive_reasoning/runners/abductive_reasoning'
|
|
8
|
+
|
|
9
|
+
module Legion
|
|
10
|
+
module Extensions
|
|
11
|
+
module AbductiveReasoning
|
|
12
|
+
class Client
|
|
13
|
+
include Runners::AbductiveReasoning
|
|
14
|
+
|
|
15
|
+
def initialize(engine: nil)
|
|
16
|
+
@engine = engine || Helpers::AbductionEngine.new
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module AbductiveReasoning
|
|
6
|
+
module Helpers
|
|
7
|
+
class AbductionEngine
|
|
8
|
+
include Constants
|
|
9
|
+
|
|
10
|
+
def initialize
|
|
11
|
+
@observations = {}
|
|
12
|
+
@hypotheses = {}
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def record_observation(content:, domain:, surprise_level: :notable, context: {})
|
|
16
|
+
obs = Observation.new(
|
|
17
|
+
content: content,
|
|
18
|
+
domain: domain,
|
|
19
|
+
surprise_level: surprise_level,
|
|
20
|
+
context: context
|
|
21
|
+
)
|
|
22
|
+
prune_observations if @observations.size >= Constants::MAX_OBSERVATIONS
|
|
23
|
+
@observations[obs.id] = obs
|
|
24
|
+
obs
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def generate_hypothesis(content:, observation_ids:, domain:, simplicity:,
|
|
28
|
+
explanatory_power:, prior_probability: Constants::DEFAULT_PLAUSIBILITY)
|
|
29
|
+
hyp = Hypothesis.new(
|
|
30
|
+
content: content,
|
|
31
|
+
observation_ids: observation_ids,
|
|
32
|
+
domain: domain,
|
|
33
|
+
simplicity: simplicity,
|
|
34
|
+
explanatory_power: explanatory_power,
|
|
35
|
+
prior_probability: prior_probability
|
|
36
|
+
)
|
|
37
|
+
prune_hypotheses if @hypotheses.size >= Constants::MAX_HYPOTHESES
|
|
38
|
+
@hypotheses[hyp.id] = hyp
|
|
39
|
+
hyp
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def evaluate_hypothesis(hypothesis_id:)
|
|
43
|
+
hyp = @hypotheses[hypothesis_id]
|
|
44
|
+
return { found: false } unless hyp
|
|
45
|
+
|
|
46
|
+
hyp.instance_variable_set(:@last_evaluated_at, Time.now.utc)
|
|
47
|
+
ranked = ranked_hypotheses_for_observations(hyp.observation_ids)
|
|
48
|
+
rank = ranked.index { |h| h.id == hypothesis_id }.to_i + 1
|
|
49
|
+
|
|
50
|
+
{
|
|
51
|
+
score: hyp.overall_score,
|
|
52
|
+
rank: rank,
|
|
53
|
+
quality_label: hyp.quality_label
|
|
54
|
+
}
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def add_evidence(hypothesis_id:, supporting:)
|
|
58
|
+
hyp = @hypotheses[hypothesis_id]
|
|
59
|
+
return { found: false } unless hyp
|
|
60
|
+
|
|
61
|
+
hyp.add_evidence(supporting: supporting)
|
|
62
|
+
{ found: true, hypothesis_id: hypothesis_id, state: hyp.state, plausibility: hyp.plausibility }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def best_explanation(observation_id:)
|
|
66
|
+
candidates = active_hypotheses_for(observation_id)
|
|
67
|
+
return nil if candidates.empty?
|
|
68
|
+
|
|
69
|
+
candidates.max_by(&:overall_score)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def competing_hypotheses(observation_id:)
|
|
73
|
+
active_hypotheses_for(observation_id).sort_by { |h| -h.overall_score }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def refute_hypothesis(hypothesis_id:)
|
|
77
|
+
hyp = @hypotheses[hypothesis_id]
|
|
78
|
+
return { found: false } unless hyp
|
|
79
|
+
|
|
80
|
+
hyp.refute!
|
|
81
|
+
{ found: true, hypothesis_id: hypothesis_id, state: hyp.state }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def find_by_domain(domain:)
|
|
85
|
+
@hypotheses.values.select { |h| h.domain == domain && h.state != :refuted }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def unexplained_observations
|
|
89
|
+
@observations.values.reject do |obs|
|
|
90
|
+
@hypotheses.values.any? do |h|
|
|
91
|
+
h.state == :supported && h.observation_ids.include?(obs.id)
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def decay_stale
|
|
97
|
+
cutoff = Time.now.utc - Constants::STALE_THRESHOLD
|
|
98
|
+
decayed = 0
|
|
99
|
+
@hypotheses.each_value do |hyp|
|
|
100
|
+
next if hyp.state == :refuted
|
|
101
|
+
next if hyp.last_evaluated_at >= cutoff
|
|
102
|
+
|
|
103
|
+
hyp.plausibility = (hyp.plausibility - Constants::DECAY_RATE).clamp(
|
|
104
|
+
Constants::PLAUSIBILITY_FLOOR,
|
|
105
|
+
Constants::PLAUSIBILITY_CEILING
|
|
106
|
+
)
|
|
107
|
+
decayed += 1
|
|
108
|
+
end
|
|
109
|
+
decayed
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def prune_refuted
|
|
113
|
+
before = @hypotheses.size
|
|
114
|
+
@hypotheses.delete_if { |_, h| h.state == :refuted }
|
|
115
|
+
before - @hypotheses.size
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def to_h
|
|
119
|
+
{
|
|
120
|
+
observation_count: @observations.size,
|
|
121
|
+
hypothesis_count: @hypotheses.size,
|
|
122
|
+
supported_count: @hypotheses.values.count { |h| h.state == :supported },
|
|
123
|
+
refuted_count: @hypotheses.values.count { |h| h.state == :refuted },
|
|
124
|
+
candidate_count: @hypotheses.values.count { |h| h.state == :candidate },
|
|
125
|
+
unexplained_count: unexplained_observations.size
|
|
126
|
+
}
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
private
|
|
130
|
+
|
|
131
|
+
def active_hypotheses_for(observation_id)
|
|
132
|
+
@hypotheses.values.select do |h|
|
|
133
|
+
h.state != :refuted && h.observation_ids.include?(observation_id)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def ranked_hypotheses_for_observations(observation_ids)
|
|
138
|
+
obs_set = Array(observation_ids)
|
|
139
|
+
@hypotheses.values
|
|
140
|
+
.select { |h| h.state != :refuted && h.observation_ids.intersect?(obs_set) }
|
|
141
|
+
.sort_by { |h| -h.overall_score }
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def prune_observations
|
|
145
|
+
sorted = @observations.values.sort_by(&:created_at)
|
|
146
|
+
excess = @observations.size - Constants::MAX_OBSERVATIONS + 1
|
|
147
|
+
sorted.first(excess).each { |obs| @observations.delete(obs.id) }
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def prune_hypotheses
|
|
151
|
+
candidates = @hypotheses.values.select { |h| h.state == :candidate }
|
|
152
|
+
sorted = candidates.sort_by(&:created_at)
|
|
153
|
+
excess = @hypotheses.size - Constants::MAX_HYPOTHESES + 1
|
|
154
|
+
sorted.first(excess).each { |h| @hypotheses.delete(h.id) }
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module AbductiveReasoning
|
|
6
|
+
module Helpers
|
|
7
|
+
module Constants
|
|
8
|
+
MAX_OBSERVATIONS = 200
|
|
9
|
+
MAX_HYPOTHESES = 100
|
|
10
|
+
MAX_EXPLANATIONS = 500
|
|
11
|
+
MAX_HISTORY = 300
|
|
12
|
+
DEFAULT_PLAUSIBILITY = 0.5
|
|
13
|
+
PLAUSIBILITY_FLOOR = 0.0
|
|
14
|
+
PLAUSIBILITY_CEILING = 1.0
|
|
15
|
+
SIMPLICITY_WEIGHT = 0.3
|
|
16
|
+
EXPLANATORY_POWER_WEIGHT = 0.4
|
|
17
|
+
PRIOR_WEIGHT = 0.3
|
|
18
|
+
EVIDENCE_BOOST = 0.1
|
|
19
|
+
CONTRADICTION_PENALTY = 0.2
|
|
20
|
+
DECAY_RATE = 0.02
|
|
21
|
+
STALE_THRESHOLD = 120
|
|
22
|
+
SURPRISE_LEVELS = %i[trivial expected notable surprising shocking].freeze
|
|
23
|
+
HYPOTHESIS_STATES = %i[candidate supported refuted].freeze
|
|
24
|
+
QUALITY_LABELS = {
|
|
25
|
+
(0.8..) => :compelling,
|
|
26
|
+
(0.6...0.8) => :plausible,
|
|
27
|
+
(0.4...0.6) => :possible,
|
|
28
|
+
(0.2...0.4) => :weak,
|
|
29
|
+
(..0.2) => :implausible
|
|
30
|
+
}.freeze
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module Extensions
|
|
7
|
+
module AbductiveReasoning
|
|
8
|
+
module Helpers
|
|
9
|
+
class Hypothesis
|
|
10
|
+
include Constants
|
|
11
|
+
|
|
12
|
+
attr_reader :id, :content, :observation_ids, :domain,
|
|
13
|
+
:simplicity, :explanatory_power, :prior_probability,
|
|
14
|
+
:evidence_for, :evidence_against, :state,
|
|
15
|
+
:created_at, :last_evaluated_at
|
|
16
|
+
attr_accessor :plausibility
|
|
17
|
+
|
|
18
|
+
SUPPORT_EVIDENCE_THRESHOLD = 3
|
|
19
|
+
|
|
20
|
+
def initialize(content:, observation_ids:, domain:, simplicity:, explanatory_power:,
|
|
21
|
+
prior_probability: Constants::DEFAULT_PLAUSIBILITY)
|
|
22
|
+
@id = SecureRandom.uuid
|
|
23
|
+
@content = content
|
|
24
|
+
@observation_ids = Array(observation_ids)
|
|
25
|
+
@domain = domain
|
|
26
|
+
@plausibility = prior_probability
|
|
27
|
+
@simplicity = simplicity
|
|
28
|
+
@explanatory_power = explanatory_power
|
|
29
|
+
@prior_probability = prior_probability
|
|
30
|
+
@evidence_for = 0
|
|
31
|
+
@evidence_against = 0
|
|
32
|
+
@state = :candidate
|
|
33
|
+
@created_at = Time.now.utc
|
|
34
|
+
@last_evaluated_at = Time.now.utc
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def overall_score
|
|
38
|
+
(Constants::SIMPLICITY_WEIGHT * @simplicity) +
|
|
39
|
+
(Constants::EXPLANATORY_POWER_WEIGHT * @explanatory_power) +
|
|
40
|
+
(Constants::PRIOR_WEIGHT * @prior_probability)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def add_evidence(supporting:)
|
|
44
|
+
@last_evaluated_at = Time.now.utc
|
|
45
|
+
if supporting
|
|
46
|
+
@evidence_for += 1
|
|
47
|
+
@plausibility = (@plausibility + Constants::EVIDENCE_BOOST).clamp(
|
|
48
|
+
Constants::PLAUSIBILITY_FLOOR,
|
|
49
|
+
Constants::PLAUSIBILITY_CEILING
|
|
50
|
+
)
|
|
51
|
+
else
|
|
52
|
+
@evidence_against += 1
|
|
53
|
+
@plausibility = (@plausibility - Constants::CONTRADICTION_PENALTY).clamp(
|
|
54
|
+
Constants::PLAUSIBILITY_FLOOR,
|
|
55
|
+
Constants::PLAUSIBILITY_CEILING
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
support! if @evidence_for >= SUPPORT_EVIDENCE_THRESHOLD && @state == :candidate
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def refute!
|
|
62
|
+
@state = :refuted
|
|
63
|
+
@last_evaluated_at = Time.now.utc
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def support!
|
|
67
|
+
@state = :supported
|
|
68
|
+
@last_evaluated_at = Time.now.utc
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def quality_label
|
|
72
|
+
score = overall_score
|
|
73
|
+
Constants::QUALITY_LABELS.each do |range, label|
|
|
74
|
+
return label if range.include?(score)
|
|
75
|
+
end
|
|
76
|
+
:implausible
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def to_h
|
|
80
|
+
{
|
|
81
|
+
id: @id,
|
|
82
|
+
content: @content,
|
|
83
|
+
observation_ids: @observation_ids,
|
|
84
|
+
domain: @domain,
|
|
85
|
+
plausibility: @plausibility,
|
|
86
|
+
simplicity: @simplicity,
|
|
87
|
+
explanatory_power: @explanatory_power,
|
|
88
|
+
prior_probability: @prior_probability,
|
|
89
|
+
evidence_for: @evidence_for,
|
|
90
|
+
evidence_against: @evidence_against,
|
|
91
|
+
state: @state,
|
|
92
|
+
overall_score: overall_score,
|
|
93
|
+
quality_label: quality_label,
|
|
94
|
+
created_at: @created_at,
|
|
95
|
+
last_evaluated_at: @last_evaluated_at
|
|
96
|
+
}
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module Extensions
|
|
7
|
+
module AbductiveReasoning
|
|
8
|
+
module Helpers
|
|
9
|
+
class Observation
|
|
10
|
+
attr_reader :id, :content, :domain, :surprise_level, :context, :created_at
|
|
11
|
+
|
|
12
|
+
def initialize(content:, domain:, surprise_level: :notable, context: {})
|
|
13
|
+
@id = SecureRandom.uuid
|
|
14
|
+
@content = content
|
|
15
|
+
@domain = domain
|
|
16
|
+
@surprise_level = surprise_level
|
|
17
|
+
@context = context
|
|
18
|
+
@created_at = Time.now.utc
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def to_h
|
|
22
|
+
{
|
|
23
|
+
id: @id,
|
|
24
|
+
content: @content,
|
|
25
|
+
domain: @domain,
|
|
26
|
+
surprise_level: @surprise_level,
|
|
27
|
+
context: @context,
|
|
28
|
+
created_at: @created_at
|
|
29
|
+
}
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module AbductiveReasoning
|
|
6
|
+
module Runners
|
|
7
|
+
module AbductiveReasoning
|
|
8
|
+
include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers) &&
|
|
9
|
+
Legion::Extensions::Helpers.const_defined?(:Lex)
|
|
10
|
+
|
|
11
|
+
def record_observation(content:, domain:, surprise_level: :notable, context: {}, **)
|
|
12
|
+
unless Helpers::Constants::SURPRISE_LEVELS.include?(surprise_level)
|
|
13
|
+
return { success: false, error: :invalid_surprise_level,
|
|
14
|
+
valid_levels: Helpers::Constants::SURPRISE_LEVELS }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
obs = engine.record_observation(
|
|
18
|
+
content: content,
|
|
19
|
+
domain: domain,
|
|
20
|
+
surprise_level: surprise_level,
|
|
21
|
+
context: context
|
|
22
|
+
)
|
|
23
|
+
Legion::Logging.debug "[abductive_reasoning] observation recorded: id=#{obs.id[0..7]} " \
|
|
24
|
+
"domain=#{domain} surprise=#{surprise_level}"
|
|
25
|
+
{ success: true, observation: obs.to_h }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def generate_hypothesis(content:, observation_ids:, domain:, simplicity:,
|
|
29
|
+
explanatory_power:, prior_probability: nil, **)
|
|
30
|
+
prior = prior_probability || Helpers::Constants::DEFAULT_PLAUSIBILITY
|
|
31
|
+
hyp = engine.generate_hypothesis(
|
|
32
|
+
content: content,
|
|
33
|
+
observation_ids: observation_ids,
|
|
34
|
+
domain: domain,
|
|
35
|
+
simplicity: simplicity,
|
|
36
|
+
explanatory_power: explanatory_power,
|
|
37
|
+
prior_probability: prior
|
|
38
|
+
)
|
|
39
|
+
Legion::Logging.debug "[abductive_reasoning] hypothesis generated: id=#{hyp.id[0..7]} " \
|
|
40
|
+
"domain=#{domain} score=#{hyp.overall_score.round(3)}"
|
|
41
|
+
{ success: true, hypothesis: hyp.to_h }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def evaluate_hypothesis(hypothesis_id:, **)
|
|
45
|
+
result = engine.evaluate_hypothesis(hypothesis_id: hypothesis_id)
|
|
46
|
+
if result[:found] == false
|
|
47
|
+
Legion::Logging.debug "[abductive_reasoning] evaluate: not found id=#{hypothesis_id[0..7]}"
|
|
48
|
+
return { success: false, error: :not_found }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
Legion::Logging.debug "[abductive_reasoning] evaluate: id=#{hypothesis_id[0..7]} " \
|
|
52
|
+
"score=#{result[:score].round(3)} rank=#{result[:rank]} label=#{result[:quality_label]}"
|
|
53
|
+
{ success: true }.merge(result)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def add_hypothesis_evidence(hypothesis_id:, supporting:, **)
|
|
57
|
+
result = engine.add_evidence(hypothesis_id: hypothesis_id, supporting: supporting)
|
|
58
|
+
if result[:found] == false
|
|
59
|
+
Legion::Logging.debug "[abductive_reasoning] add_evidence: not found id=#{hypothesis_id[0..7]}"
|
|
60
|
+
return { success: false, error: :not_found }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
Legion::Logging.debug "[abductive_reasoning] evidence added: id=#{hypothesis_id[0..7]} " \
|
|
64
|
+
"supporting=#{supporting} state=#{result[:state]}"
|
|
65
|
+
{ success: true }.merge(result)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def best_explanation(observation_id:, **)
|
|
69
|
+
hyp = engine.best_explanation(observation_id: observation_id)
|
|
70
|
+
if hyp
|
|
71
|
+
Legion::Logging.debug "[abductive_reasoning] best_explanation: obs=#{observation_id[0..7]} " \
|
|
72
|
+
"hyp=#{hyp.id[0..7]} score=#{hyp.overall_score.round(3)}"
|
|
73
|
+
{ success: true, found: true, hypothesis: hyp.to_h }
|
|
74
|
+
else
|
|
75
|
+
Legion::Logging.debug "[abductive_reasoning] best_explanation: obs=#{observation_id[0..7]} none found"
|
|
76
|
+
{ success: true, found: false }
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def competing_hypotheses(observation_id:, **)
|
|
81
|
+
ranked = engine.competing_hypotheses(observation_id: observation_id)
|
|
82
|
+
Legion::Logging.debug "[abductive_reasoning] competing_hypotheses: obs=#{observation_id[0..7]} count=#{ranked.size}"
|
|
83
|
+
{ success: true, hypotheses: ranked.map(&:to_h), count: ranked.size }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def refute_hypothesis(hypothesis_id:, **)
|
|
87
|
+
result = engine.refute_hypothesis(hypothesis_id: hypothesis_id)
|
|
88
|
+
if result[:found] == false
|
|
89
|
+
Legion::Logging.debug "[abductive_reasoning] refute: not found id=#{hypothesis_id[0..7]}"
|
|
90
|
+
return { success: false, error: :not_found }
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
Legion::Logging.debug "[abductive_reasoning] hypothesis refuted: id=#{hypothesis_id[0..7]}"
|
|
94
|
+
{ success: true }.merge(result)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def unexplained_observations(**)
|
|
98
|
+
observations = engine.unexplained_observations
|
|
99
|
+
Legion::Logging.debug "[abductive_reasoning] unexplained_observations: count=#{observations.size}"
|
|
100
|
+
{ success: true, observations: observations.map(&:to_h), count: observations.size }
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def update_abductive_reasoning(**)
|
|
104
|
+
decayed = engine.decay_stale
|
|
105
|
+
pruned = engine.prune_refuted
|
|
106
|
+
Legion::Logging.debug "[abductive_reasoning] update cycle: decayed=#{decayed} pruned=#{pruned}"
|
|
107
|
+
{ success: true, decayed: decayed, pruned: pruned }
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def abductive_reasoning_stats(**)
|
|
111
|
+
stats = engine.to_h
|
|
112
|
+
Legion::Logging.debug "[abductive_reasoning] stats: #{stats.inspect}"
|
|
113
|
+
{ success: true }.merge(stats)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
private
|
|
117
|
+
|
|
118
|
+
def engine
|
|
119
|
+
@engine ||= Helpers::AbductionEngine.new
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/abductive_reasoning/version'
|
|
4
|
+
require 'legion/extensions/abductive_reasoning/helpers/constants'
|
|
5
|
+
require 'legion/extensions/abductive_reasoning/helpers/observation'
|
|
6
|
+
require 'legion/extensions/abductive_reasoning/helpers/hypothesis'
|
|
7
|
+
require 'legion/extensions/abductive_reasoning/helpers/abduction_engine'
|
|
8
|
+
require 'legion/extensions/abductive_reasoning/runners/abductive_reasoning'
|
|
9
|
+
|
|
10
|
+
module Legion
|
|
11
|
+
module Extensions
|
|
12
|
+
module AbductiveReasoning
|
|
13
|
+
extend Legion::Extensions::Core if Legion::Extensions.const_defined? :Core
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/abductive_reasoning/client'
|
|
4
|
+
|
|
5
|
+
RSpec.describe Legion::Extensions::AbductiveReasoning::Client do
|
|
6
|
+
it 'responds to all runner methods' do
|
|
7
|
+
client = described_class.new
|
|
8
|
+
expect(client).to respond_to(:record_observation)
|
|
9
|
+
expect(client).to respond_to(:generate_hypothesis)
|
|
10
|
+
expect(client).to respond_to(:evaluate_hypothesis)
|
|
11
|
+
expect(client).to respond_to(:add_hypothesis_evidence)
|
|
12
|
+
expect(client).to respond_to(:best_explanation)
|
|
13
|
+
expect(client).to respond_to(:competing_hypotheses)
|
|
14
|
+
expect(client).to respond_to(:refute_hypothesis)
|
|
15
|
+
expect(client).to respond_to(:unexplained_observations)
|
|
16
|
+
expect(client).to respond_to(:update_abductive_reasoning)
|
|
17
|
+
expect(client).to respond_to(:abductive_reasoning_stats)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
it 'accepts a custom engine' do
|
|
21
|
+
custom_engine = Legion::Extensions::AbductiveReasoning::Helpers::AbductionEngine.new
|
|
22
|
+
client = described_class.new(engine: custom_engine)
|
|
23
|
+
expect(client).to be_a(described_class)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/abductive_reasoning/client'
|
|
4
|
+
|
|
5
|
+
RSpec.describe Legion::Extensions::AbductiveReasoning::Runners::AbductiveReasoning do
|
|
6
|
+
let(:client) { Legion::Extensions::AbductiveReasoning::Client.new }
|
|
7
|
+
|
|
8
|
+
describe '#record_observation' do
|
|
9
|
+
it 'records an observation and returns success' do
|
|
10
|
+
result = client.record_observation(
|
|
11
|
+
content: 'CPU usage spiked to 100% unexpectedly',
|
|
12
|
+
domain: :system_health,
|
|
13
|
+
surprise_level: :surprising
|
|
14
|
+
)
|
|
15
|
+
expect(result[:success]).to be true
|
|
16
|
+
expect(result[:observation][:id]).to match(/\A[0-9a-f-]{36}\z/)
|
|
17
|
+
expect(result[:observation][:domain]).to eq(:system_health)
|
|
18
|
+
expect(result[:observation][:surprise_level]).to eq(:surprising)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
it 'rejects invalid surprise level' do
|
|
22
|
+
result = client.record_observation(
|
|
23
|
+
content: 'some fact',
|
|
24
|
+
domain: :test,
|
|
25
|
+
surprise_level: :unknown_level
|
|
26
|
+
)
|
|
27
|
+
expect(result[:success]).to be false
|
|
28
|
+
expect(result[:error]).to eq(:invalid_surprise_level)
|
|
29
|
+
expect(result[:valid_levels]).to eq(Legion::Extensions::AbductiveReasoning::Helpers::Constants::SURPRISE_LEVELS)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
it 'uses notable as default surprise level' do
|
|
33
|
+
result = client.record_observation(content: 'a fact', domain: :test)
|
|
34
|
+
expect(result[:observation][:surprise_level]).to eq(:notable)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
it 'stores context hash' do
|
|
38
|
+
result = client.record_observation(
|
|
39
|
+
content: 'memory leak detected',
|
|
40
|
+
domain: :system,
|
|
41
|
+
context: { pid: 1234, rss_mb: 4096 }
|
|
42
|
+
)
|
|
43
|
+
expect(result[:observation][:context]).to eq({ pid: 1234, rss_mb: 4096 })
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
describe '#generate_hypothesis' do
|
|
48
|
+
let(:obs_result) { client.record_observation(content: 'service crashed', domain: :ops) }
|
|
49
|
+
let(:obs_id) { obs_result[:observation][:id] }
|
|
50
|
+
|
|
51
|
+
it 'generates a hypothesis and returns success' do
|
|
52
|
+
result = client.generate_hypothesis(
|
|
53
|
+
content: 'Memory leak caused service crash',
|
|
54
|
+
observation_ids: [obs_id],
|
|
55
|
+
domain: :ops,
|
|
56
|
+
simplicity: 0.7,
|
|
57
|
+
explanatory_power: 0.8
|
|
58
|
+
)
|
|
59
|
+
expect(result[:success]).to be true
|
|
60
|
+
expect(result[:hypothesis][:id]).to match(/\A[0-9a-f-]{36}\z/)
|
|
61
|
+
expect(result[:hypothesis][:state]).to eq(:candidate)
|
|
62
|
+
expect(result[:hypothesis][:overall_score]).to be > 0
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
it 'uses default prior probability when not given' do
|
|
66
|
+
result = client.generate_hypothesis(
|
|
67
|
+
content: 'Network partition caused crash',
|
|
68
|
+
observation_ids: [obs_id],
|
|
69
|
+
domain: :ops,
|
|
70
|
+
simplicity: 0.5,
|
|
71
|
+
explanatory_power: 0.6
|
|
72
|
+
)
|
|
73
|
+
expect(result[:hypothesis][:prior_probability]).to eq(
|
|
74
|
+
Legion::Extensions::AbductiveReasoning::Helpers::Constants::DEFAULT_PLAUSIBILITY
|
|
75
|
+
)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
it 'uses provided prior probability' do
|
|
79
|
+
result = client.generate_hypothesis(
|
|
80
|
+
content: 'Config error',
|
|
81
|
+
observation_ids: [obs_id],
|
|
82
|
+
domain: :ops,
|
|
83
|
+
simplicity: 0.9,
|
|
84
|
+
explanatory_power: 0.7,
|
|
85
|
+
prior_probability: 0.8
|
|
86
|
+
)
|
|
87
|
+
expect(result[:hypothesis][:prior_probability]).to eq(0.8)
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
describe '#evaluate_hypothesis' do
|
|
92
|
+
let(:obs_id) { client.record_observation(content: 'anomaly', domain: :test)[:observation][:id] }
|
|
93
|
+
let(:hyp_id) do
|
|
94
|
+
client.generate_hypothesis(
|
|
95
|
+
content: 'hypothesis A',
|
|
96
|
+
observation_ids: [obs_id],
|
|
97
|
+
domain: :test,
|
|
98
|
+
simplicity: 0.8,
|
|
99
|
+
explanatory_power: 0.9
|
|
100
|
+
)[:hypothesis][:id]
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
it 'evaluates a known hypothesis' do
|
|
104
|
+
result = client.evaluate_hypothesis(hypothesis_id: hyp_id)
|
|
105
|
+
expect(result[:success]).to be true
|
|
106
|
+
expect(result[:score]).to be_a(Float)
|
|
107
|
+
expect(result[:rank]).to be >= 1
|
|
108
|
+
expect(result[:quality_label]).to be_a(Symbol)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
it 'returns not_found for unknown hypothesis' do
|
|
112
|
+
result = client.evaluate_hypothesis(hypothesis_id: 'nonexistent-id')
|
|
113
|
+
expect(result[:success]).to be false
|
|
114
|
+
expect(result[:error]).to eq(:not_found)
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
describe '#add_hypothesis_evidence' do
|
|
119
|
+
let(:obs_id) { client.record_observation(content: 'strange output', domain: :test)[:observation][:id] }
|
|
120
|
+
let(:hyp_id) do
|
|
121
|
+
client.generate_hypothesis(
|
|
122
|
+
content: 'bad config',
|
|
123
|
+
observation_ids: [obs_id],
|
|
124
|
+
domain: :test,
|
|
125
|
+
simplicity: 0.6,
|
|
126
|
+
explanatory_power: 0.7
|
|
127
|
+
)[:hypothesis][:id]
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
it 'adds supporting evidence and boosts plausibility' do
|
|
131
|
+
before_plausibility = client.evaluate_hypothesis(hypothesis_id: hyp_id)[:score]
|
|
132
|
+
client.add_hypothesis_evidence(hypothesis_id: hyp_id, supporting: true)
|
|
133
|
+
hyp_after = client.evaluate_hypothesis(hypothesis_id: hyp_id)
|
|
134
|
+
expect(hyp_after[:success]).to be true
|
|
135
|
+
expect(before_plausibility).to be_a(Float)
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
it 'transitions to supported after enough supporting evidence' do
|
|
139
|
+
3.times { client.add_hypothesis_evidence(hypothesis_id: hyp_id, supporting: true) }
|
|
140
|
+
result = client.add_hypothesis_evidence(hypothesis_id: hyp_id, supporting: true)
|
|
141
|
+
expect(result[:state]).to eq(:supported)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
it 'adds contradicting evidence' do
|
|
145
|
+
result = client.add_hypothesis_evidence(hypothesis_id: hyp_id, supporting: false)
|
|
146
|
+
expect(result[:success]).to be true
|
|
147
|
+
expect(result[:state]).to eq(:candidate)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
it 'returns not_found for unknown hypothesis' do
|
|
151
|
+
result = client.add_hypothesis_evidence(hypothesis_id: 'missing', supporting: true)
|
|
152
|
+
expect(result[:success]).to be false
|
|
153
|
+
expect(result[:error]).to eq(:not_found)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
describe '#best_explanation' do
|
|
158
|
+
let(:obs_id) { client.record_observation(content: 'disk full warning', domain: :storage)[:observation][:id] }
|
|
159
|
+
|
|
160
|
+
it 'returns nil found when no hypotheses exist' do
|
|
161
|
+
result = client.best_explanation(observation_id: obs_id)
|
|
162
|
+
expect(result[:success]).to be true
|
|
163
|
+
expect(result[:found]).to be false
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
it 'returns best hypothesis by overall_score' do
|
|
167
|
+
client.generate_hypothesis(
|
|
168
|
+
content: 'log files grew unbounded',
|
|
169
|
+
observation_ids: [obs_id],
|
|
170
|
+
domain: :storage,
|
|
171
|
+
simplicity: 0.9,
|
|
172
|
+
explanatory_power: 0.9,
|
|
173
|
+
prior_probability: 0.8
|
|
174
|
+
)
|
|
175
|
+
client.generate_hypothesis(
|
|
176
|
+
content: 'backup job failed and left temp files',
|
|
177
|
+
observation_ids: [obs_id],
|
|
178
|
+
domain: :storage,
|
|
179
|
+
simplicity: 0.3,
|
|
180
|
+
explanatory_power: 0.4,
|
|
181
|
+
prior_probability: 0.2
|
|
182
|
+
)
|
|
183
|
+
result = client.best_explanation(observation_id: obs_id)
|
|
184
|
+
expect(result[:success]).to be true
|
|
185
|
+
expect(result[:found]).to be true
|
|
186
|
+
expect(result[:hypothesis][:content]).to eq('log files grew unbounded')
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
describe '#competing_hypotheses' do
|
|
191
|
+
let(:obs_id) { client.record_observation(content: 'latency spike', domain: :perf)[:observation][:id] }
|
|
192
|
+
|
|
193
|
+
it 'returns empty list when no hypotheses' do
|
|
194
|
+
result = client.competing_hypotheses(observation_id: obs_id)
|
|
195
|
+
expect(result[:success]).to be true
|
|
196
|
+
expect(result[:count]).to eq(0)
|
|
197
|
+
expect(result[:hypotheses]).to eq([])
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
it 'returns hypotheses sorted by score descending' do
|
|
201
|
+
client.generate_hypothesis(
|
|
202
|
+
content: 'DB slow query',
|
|
203
|
+
observation_ids: [obs_id],
|
|
204
|
+
domain: :perf,
|
|
205
|
+
simplicity: 0.8,
|
|
206
|
+
explanatory_power: 0.8
|
|
207
|
+
)
|
|
208
|
+
client.generate_hypothesis(
|
|
209
|
+
content: 'Network congestion',
|
|
210
|
+
observation_ids: [obs_id],
|
|
211
|
+
domain: :perf,
|
|
212
|
+
simplicity: 0.2,
|
|
213
|
+
explanatory_power: 0.2
|
|
214
|
+
)
|
|
215
|
+
result = client.competing_hypotheses(observation_id: obs_id)
|
|
216
|
+
expect(result[:count]).to eq(2)
|
|
217
|
+
scores = result[:hypotheses].map { |h| h[:overall_score] }
|
|
218
|
+
expect(scores).to eq(scores.sort.reverse)
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
describe '#refute_hypothesis' do
|
|
223
|
+
let(:obs_id) { client.record_observation(content: 'error 500', domain: :web)[:observation][:id] }
|
|
224
|
+
let(:hyp_id) do
|
|
225
|
+
client.generate_hypothesis(
|
|
226
|
+
content: 'out of memory',
|
|
227
|
+
observation_ids: [obs_id],
|
|
228
|
+
domain: :web,
|
|
229
|
+
simplicity: 0.5,
|
|
230
|
+
explanatory_power: 0.5
|
|
231
|
+
)[:hypothesis][:id]
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
it 'refutes a hypothesis' do
|
|
235
|
+
result = client.refute_hypothesis(hypothesis_id: hyp_id)
|
|
236
|
+
expect(result[:success]).to be true
|
|
237
|
+
expect(result[:state]).to eq(:refuted)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
it 'excludes refuted hypotheses from competing list' do
|
|
241
|
+
client.refute_hypothesis(hypothesis_id: hyp_id)
|
|
242
|
+
result = client.competing_hypotheses(observation_id: obs_id)
|
|
243
|
+
expect(result[:count]).to eq(0)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
it 'returns not_found for unknown hypothesis' do
|
|
247
|
+
result = client.refute_hypothesis(hypothesis_id: 'ghost')
|
|
248
|
+
expect(result[:success]).to be false
|
|
249
|
+
expect(result[:error]).to eq(:not_found)
|
|
250
|
+
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
describe '#unexplained_observations' do
|
|
254
|
+
it 'returns observations with no supported hypothesis' do
|
|
255
|
+
client.record_observation(content: 'unexplained event', domain: :mystery)
|
|
256
|
+
result = client.unexplained_observations
|
|
257
|
+
expect(result[:success]).to be true
|
|
258
|
+
expect(result[:count]).to be >= 1
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
it 'excludes observations explained by a supported hypothesis' do
|
|
262
|
+
obs_id = client.record_observation(content: 'explained', domain: :test)[:observation][:id]
|
|
263
|
+
hyp_id = client.generate_hypothesis(
|
|
264
|
+
content: 'known cause',
|
|
265
|
+
observation_ids: [obs_id],
|
|
266
|
+
domain: :test,
|
|
267
|
+
simplicity: 0.9,
|
|
268
|
+
explanatory_power: 0.9
|
|
269
|
+
)[:hypothesis][:id]
|
|
270
|
+
3.times { client.add_hypothesis_evidence(hypothesis_id: hyp_id, supporting: true) }
|
|
271
|
+
client.add_hypothesis_evidence(hypothesis_id: hyp_id, supporting: true)
|
|
272
|
+
|
|
273
|
+
result = client.unexplained_observations
|
|
274
|
+
obs_ids = result[:observations].map { |o| o[:id] }
|
|
275
|
+
expect(obs_ids).not_to include(obs_id)
|
|
276
|
+
end
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
describe '#update_abductive_reasoning' do
|
|
280
|
+
it 'runs decay and prune cycle' do
|
|
281
|
+
result = client.update_abductive_reasoning
|
|
282
|
+
expect(result[:success]).to be true
|
|
283
|
+
expect(result[:decayed]).to be >= 0
|
|
284
|
+
expect(result[:pruned]).to be >= 0
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
it 'prunes refuted hypotheses' do
|
|
288
|
+
obs_id = client.record_observation(content: 'obs', domain: :test)[:observation][:id]
|
|
289
|
+
hyp_id = client.generate_hypothesis(
|
|
290
|
+
content: 'bad hyp',
|
|
291
|
+
observation_ids: [obs_id],
|
|
292
|
+
domain: :test,
|
|
293
|
+
simplicity: 0.5,
|
|
294
|
+
explanatory_power: 0.5
|
|
295
|
+
)[:hypothesis][:id]
|
|
296
|
+
client.refute_hypothesis(hypothesis_id: hyp_id)
|
|
297
|
+
|
|
298
|
+
result = client.update_abductive_reasoning
|
|
299
|
+
expect(result[:pruned]).to eq(1)
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
describe '#abductive_reasoning_stats' do
|
|
304
|
+
it 'returns stats hash' do
|
|
305
|
+
result = client.abductive_reasoning_stats
|
|
306
|
+
expect(result[:success]).to be true
|
|
307
|
+
expect(result).to have_key(:observation_count)
|
|
308
|
+
expect(result).to have_key(:hypothesis_count)
|
|
309
|
+
expect(result).to have_key(:supported_count)
|
|
310
|
+
expect(result).to have_key(:refuted_count)
|
|
311
|
+
expect(result).to have_key(:candidate_count)
|
|
312
|
+
expect(result).to have_key(:unexplained_count)
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
it 'reflects accumulated state' do
|
|
316
|
+
client.record_observation(content: 'fact', domain: :test)
|
|
317
|
+
result = client.abductive_reasoning_stats
|
|
318
|
+
expect(result[:observation_count]).to be >= 1
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
describe 'quality labels' do
|
|
323
|
+
let(:obs_id) { client.record_observation(content: 'obs', domain: :test)[:observation][:id] }
|
|
324
|
+
|
|
325
|
+
it 'assigns compelling label for high-scoring hypothesis' do
|
|
326
|
+
result = client.generate_hypothesis(
|
|
327
|
+
content: 'strong hypothesis',
|
|
328
|
+
observation_ids: [obs_id],
|
|
329
|
+
domain: :test,
|
|
330
|
+
simplicity: 1.0,
|
|
331
|
+
explanatory_power: 1.0,
|
|
332
|
+
prior_probability: 1.0
|
|
333
|
+
)
|
|
334
|
+
expect(result[:hypothesis][:quality_label]).to eq(:compelling)
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
it 'assigns implausible label for low-scoring hypothesis' do
|
|
338
|
+
result = client.generate_hypothesis(
|
|
339
|
+
content: 'weak hypothesis',
|
|
340
|
+
observation_ids: [obs_id],
|
|
341
|
+
domain: :test,
|
|
342
|
+
simplicity: 0.0,
|
|
343
|
+
explanatory_power: 0.0,
|
|
344
|
+
prior_probability: 0.0
|
|
345
|
+
)
|
|
346
|
+
expect(result[:hypothesis][:quality_label]).to eq(:implausible)
|
|
347
|
+
end
|
|
348
|
+
end
|
|
349
|
+
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/abductive_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,74 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: lex-abductive-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: Peirce abductive reasoning engine — inference to the best explanation
|
|
27
|
+
for brain-modeled agentic AI
|
|
28
|
+
email:
|
|
29
|
+
- matthewdiverson@gmail.com
|
|
30
|
+
executables: []
|
|
31
|
+
extensions: []
|
|
32
|
+
extra_rdoc_files: []
|
|
33
|
+
files:
|
|
34
|
+
- Gemfile
|
|
35
|
+
- lex-abductive-reasoning.gemspec
|
|
36
|
+
- lib/legion/extensions/abductive_reasoning.rb
|
|
37
|
+
- lib/legion/extensions/abductive_reasoning/client.rb
|
|
38
|
+
- lib/legion/extensions/abductive_reasoning/helpers/abduction_engine.rb
|
|
39
|
+
- lib/legion/extensions/abductive_reasoning/helpers/constants.rb
|
|
40
|
+
- lib/legion/extensions/abductive_reasoning/helpers/hypothesis.rb
|
|
41
|
+
- lib/legion/extensions/abductive_reasoning/helpers/observation.rb
|
|
42
|
+
- lib/legion/extensions/abductive_reasoning/runners/abductive_reasoning.rb
|
|
43
|
+
- lib/legion/extensions/abductive_reasoning/version.rb
|
|
44
|
+
- spec/legion/extensions/abductive_reasoning/client_spec.rb
|
|
45
|
+
- spec/legion/extensions/abductive_reasoning/runners/abductive_reasoning_spec.rb
|
|
46
|
+
- spec/spec_helper.rb
|
|
47
|
+
homepage: https://github.com/LegionIO/lex-abductive-reasoning
|
|
48
|
+
licenses:
|
|
49
|
+
- MIT
|
|
50
|
+
metadata:
|
|
51
|
+
homepage_uri: https://github.com/LegionIO/lex-abductive-reasoning
|
|
52
|
+
source_code_uri: https://github.com/LegionIO/lex-abductive-reasoning
|
|
53
|
+
documentation_uri: https://github.com/LegionIO/lex-abductive-reasoning
|
|
54
|
+
changelog_uri: https://github.com/LegionIO/lex-abductive-reasoning
|
|
55
|
+
bug_tracker_uri: https://github.com/LegionIO/lex-abductive-reasoning/issues
|
|
56
|
+
rubygems_mfa_required: 'true'
|
|
57
|
+
rdoc_options: []
|
|
58
|
+
require_paths:
|
|
59
|
+
- lib
|
|
60
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
61
|
+
requirements:
|
|
62
|
+
- - ">="
|
|
63
|
+
- !ruby/object:Gem::Version
|
|
64
|
+
version: '3.4'
|
|
65
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
66
|
+
requirements:
|
|
67
|
+
- - ">="
|
|
68
|
+
- !ruby/object:Gem::Version
|
|
69
|
+
version: '0'
|
|
70
|
+
requirements: []
|
|
71
|
+
rubygems_version: 3.6.9
|
|
72
|
+
specification_version: 4
|
|
73
|
+
summary: LEX Abductive Reasoning
|
|
74
|
+
test_files: []
|