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 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,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ gemspec
6
+
7
+ gem 'rspec', '~> 3.13'
8
+ gem 'rubocop', '~> 1.75', require: false
9
+ gem 'rubocop-rspec', require: false
10
+
11
+ gem 'legion-gaia', path: '../../legion-gaia'
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'lib/legion/extensions/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,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module AbductiveReasoning
6
+ VERSION = '0.1.0'
7
+ end
8
+ end
9
+ 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
@@ -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: []