lex-hypothesis-testing 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: 507714e37b488f457d0272db01812b1c653b7743bf94a6d8e13d3a409e9813e7
4
+ data.tar.gz: b77ad37b8162ea096e222440f2b512021f7aacaf4d824987de6d23f2c26c4936
5
+ SHA512:
6
+ metadata.gz: 4bbef8ca040aabb9ba210897a4fb0b133e17948771d9664374accf08121fa47aa0b65f28004a5a74612c6823bbe9a35b4467a013ffa216e3c5b44b2286aca3a9
7
+ data.tar.gz: 3ac381ccf9e26507b1881e12f0962287281e8a7ec9636c0d3cdc72734312bb2e4d30af565f498cdf3e7e1f72867e62fcdcb2c05338201863f1c44207d8108f49
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,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'lib/legion/extensions/hypothesis_testing/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'lex-hypothesis-testing'
7
+ spec.version = Legion::Extensions::HypothesisTesting::VERSION
8
+ spec.authors = ['Esity']
9
+ spec.email = ['matthewdiverson@gmail.com']
10
+
11
+ spec.summary = 'LEX Hypothesis Testing'
12
+ spec.description = 'Scientific hypothesis testing cycle for brain-modeled agentic AI — ' \
13
+ 'Bayesian evidence accumulation, lifecycle management, competing hypotheses'
14
+ spec.homepage = 'https://github.com/LegionIO/lex-hypothesis-testing'
15
+ spec.license = 'MIT'
16
+ spec.required_ruby_version = '>= 3.4'
17
+
18
+ spec.metadata['homepage_uri'] = spec.homepage
19
+ spec.metadata['source_code_uri'] = 'https://github.com/LegionIO/lex-hypothesis-testing'
20
+ spec.metadata['documentation_uri'] = 'https://github.com/LegionIO/lex-hypothesis-testing'
21
+ spec.metadata['changelog_uri'] = 'https://github.com/LegionIO/lex-hypothesis-testing'
22
+ spec.metadata['bug_tracker_uri'] = 'https://github.com/LegionIO/lex-hypothesis-testing/issues'
23
+ spec.metadata['rubygems_mfa_required'] = 'true'
24
+
25
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
26
+ Dir.glob('{lib,spec}/**/*') + %w[lex-hypothesis-testing.gemspec Gemfile]
27
+ end
28
+ spec.require_paths = ['lib']
29
+ spec.add_development_dependency 'legion-gaia'
30
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/hypothesis_testing/helpers/constants'
4
+ require 'legion/extensions/hypothesis_testing/helpers/hypothesis'
5
+ require 'legion/extensions/hypothesis_testing/helpers/hypothesis_engine'
6
+ require 'legion/extensions/hypothesis_testing/runners/hypothesis_testing'
7
+
8
+ module Legion
9
+ module Extensions
10
+ module HypothesisTesting
11
+ class Client
12
+ include Runners::HypothesisTesting
13
+
14
+ def initialize(**)
15
+ @hypothesis_engine = Helpers::HypothesisEngine.new
16
+ end
17
+
18
+ private
19
+
20
+ attr_reader :hypothesis_engine
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module HypothesisTesting
6
+ module Helpers
7
+ module Constants
8
+ MAX_HYPOTHESES = 300
9
+ CONFIRMATION_THRESHOLD = 0.8
10
+ DISCONFIRMATION_THRESHOLD = 0.2
11
+ EVIDENCE_WEIGHT = 0.1
12
+ PRIOR_DEFAULT = 0.5
13
+
14
+ STATUS_LABELS = {
15
+ proposed: 'Proposed',
16
+ testing: 'Testing',
17
+ confirmed: 'Confirmed',
18
+ disconfirmed: 'Disconfirmed',
19
+ inconclusive: 'Inconclusive'
20
+ }.freeze
21
+
22
+ CONFIDENCE_LABELS = [
23
+ [0.9..1.0, 'certain'],
24
+ [0.7...0.9, 'confident'],
25
+ [0.5...0.7, 'leaning'],
26
+ [0.3...0.5, 'uncertain'],
27
+ [0.0...0.3, 'agnostic']
28
+ ].freeze
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module Legion
6
+ module Extensions
7
+ module HypothesisTesting
8
+ module Helpers
9
+ class Hypothesis
10
+ include Constants
11
+
12
+ attr_reader :id, :description, :domain, :prior, :posterior,
13
+ :evidence_count, :status, :created_at
14
+
15
+ def initialize(description:, domain: 'general', prior: Constants::PRIOR_DEFAULT)
16
+ @id = SecureRandom.uuid
17
+ @description = description
18
+ @domain = domain
19
+ @prior = prior.clamp(0.0, 1.0)
20
+ @posterior = @prior
21
+ @evidence_count = 0
22
+ @status = :proposed
23
+ @created_at = Time.now.utc
24
+ end
25
+
26
+ def update_posterior!(evidence_strength:, supporting: true)
27
+ return self if @status == :confirmed || @status == :disconfirmed
28
+
29
+ @status = :testing
30
+ weight = Constants::EVIDENCE_WEIGHT * evidence_strength.clamp(0.0, 1.0)
31
+
32
+ @posterior = if supporting
33
+ @posterior + (weight * (1.0 - @posterior))
34
+ else
35
+ @posterior - (weight * @posterior)
36
+ end
37
+
38
+ @posterior = @posterior.clamp(0.0, 1.0).round(10)
39
+ @evidence_count += 1
40
+ self
41
+ end
42
+
43
+ def confirm!
44
+ @status = :confirmed
45
+ @posterior = [@posterior, Constants::CONFIRMATION_THRESHOLD].max.round(10)
46
+ self
47
+ end
48
+
49
+ def disconfirm!
50
+ @status = :disconfirmed
51
+ @posterior = [@posterior, Constants::DISCONFIRMATION_THRESHOLD].min.round(10)
52
+ self
53
+ end
54
+
55
+ def confidence_label
56
+ Constants::CONFIDENCE_LABELS.each do |range, label|
57
+ return label if range.cover?(@posterior)
58
+ end
59
+ 'agnostic'
60
+ end
61
+
62
+ def to_h
63
+ {
64
+ id: @id,
65
+ description: @description,
66
+ domain: @domain,
67
+ prior: @prior,
68
+ posterior: @posterior,
69
+ evidence_count: @evidence_count,
70
+ status: @status,
71
+ confidence_label: confidence_label,
72
+ created_at: @created_at
73
+ }
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module HypothesisTesting
6
+ module Helpers
7
+ class HypothesisEngine
8
+ include Constants
9
+
10
+ attr_reader :hypotheses
11
+
12
+ def initialize
13
+ @hypotheses = {}
14
+ end
15
+
16
+ def propose(description:, domain: 'general', prior: Constants::PRIOR_DEFAULT)
17
+ evict_oldest! if @hypotheses.size >= Constants::MAX_HYPOTHESES
18
+
19
+ h = Hypothesis.new(description: description, domain: domain, prior: prior)
20
+ @hypotheses[h.id] = h
21
+ h
22
+ end
23
+
24
+ def test_hypothesis(hypothesis_id:, evidence_strength:, supporting: true)
25
+ h = @hypotheses[hypothesis_id]
26
+ return nil unless h
27
+
28
+ h.update_posterior!(evidence_strength: evidence_strength, supporting: supporting)
29
+ evaluate(hypothesis_id)
30
+ h
31
+ end
32
+
33
+ def evaluate(hypothesis_id)
34
+ h = @hypotheses[hypothesis_id]
35
+ return nil unless h
36
+ return h if %i[confirmed disconfirmed].include?(h.status)
37
+
38
+ if h.posterior >= Constants::CONFIRMATION_THRESHOLD
39
+ h.confirm!
40
+ elsif h.posterior <= Constants::DISCONFIRMATION_THRESHOLD
41
+ h.disconfirm!
42
+ else
43
+ h
44
+ end
45
+ end
46
+
47
+ def competing_hypotheses(domain:)
48
+ @hypotheses.values.select { |h| h.domain == domain }
49
+ end
50
+
51
+ def most_confident(limit: 5)
52
+ @hypotheses.values
53
+ .sort_by { |h| -h.posterior }
54
+ .first(limit)
55
+ end
56
+
57
+ def confirmation_rate
58
+ total = @hypotheses.values.count { |h| %i[confirmed disconfirmed].include?(h.status) }
59
+ return 0.0 if total.zero?
60
+
61
+ confirmed = @hypotheses.values.count { |h| h.status == :confirmed }
62
+ (confirmed.to_f / total).round(10)
63
+ end
64
+
65
+ def hypothesis_report
66
+ by_status = @hypotheses.values.group_by(&:status).transform_values(&:count)
67
+ {
68
+ total: @hypotheses.size,
69
+ by_status: by_status,
70
+ confirmation_rate: confirmation_rate,
71
+ most_confident: most_confident(limit: 3).map(&:to_h)
72
+ }
73
+ end
74
+
75
+ def to_h
76
+ {
77
+ hypotheses: @hypotheses.values.map(&:to_h),
78
+ confirmation_rate: confirmation_rate,
79
+ total: @hypotheses.size
80
+ }
81
+ end
82
+
83
+ private
84
+
85
+ def evict_oldest!
86
+ oldest_key = @hypotheses.min_by { |_, h| h.created_at }&.first
87
+ @hypotheses.delete(oldest_key) if oldest_key
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module Legion
6
+ module Extensions
7
+ module HypothesisTesting
8
+ module Runners
9
+ module HypothesisTesting
10
+ include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers) &&
11
+ Legion::Extensions::Helpers.const_defined?(:Lex)
12
+
13
+ def propose_hypothesis(description:, domain: 'general', prior: Helpers::Constants::PRIOR_DEFAULT, **)
14
+ h = hypothesis_engine.propose(description: description, domain: domain, prior: prior)
15
+ Legion::Logging.debug "[hypothesis_testing] proposed id=#{h.id[0..7]} domain=#{domain} prior=#{prior}"
16
+ {
17
+ hypothesis_id: h.id,
18
+ description: h.description,
19
+ domain: h.domain,
20
+ prior: h.prior,
21
+ posterior: h.posterior,
22
+ status: h.status,
23
+ confidence_label: h.confidence_label
24
+ }
25
+ end
26
+
27
+ def test_hypothesis(hypothesis_id:, evidence_strength:, supporting: true, **)
28
+ h = hypothesis_engine.test_hypothesis(
29
+ hypothesis_id: hypothesis_id,
30
+ evidence_strength: evidence_strength,
31
+ supporting: supporting
32
+ )
33
+ unless h
34
+ Legion::Logging.debug "[hypothesis_testing] test failed: #{hypothesis_id[0..7]} not found"
35
+ return { tested: false, reason: :not_found }
36
+ end
37
+
38
+ Legion::Logging.info "[hypothesis_testing] tested #{hypothesis_id[0..7]} " \
39
+ "supporting=#{supporting} strength=#{evidence_strength} " \
40
+ "posterior=#{h.posterior.round(4)} status=#{h.status}"
41
+ {
42
+ tested: true,
43
+ hypothesis_id: h.id,
44
+ posterior: h.posterior,
45
+ evidence_count: h.evidence_count,
46
+ status: h.status,
47
+ confidence_label: h.confidence_label
48
+ }
49
+ end
50
+
51
+ def evaluate_hypothesis(hypothesis_id:, **)
52
+ h = hypothesis_engine.evaluate(hypothesis_id)
53
+ unless h
54
+ Legion::Logging.debug "[hypothesis_testing] evaluate failed: #{hypothesis_id[0..7]} not found"
55
+ return { found: false }
56
+ end
57
+
58
+ Legion::Logging.debug "[hypothesis_testing] evaluated #{hypothesis_id[0..7]} status=#{h.status}"
59
+ {
60
+ found: true,
61
+ hypothesis_id: h.id,
62
+ status: h.status,
63
+ posterior: h.posterior,
64
+ confidence_label: h.confidence_label,
65
+ evidence_count: h.evidence_count
66
+ }
67
+ end
68
+
69
+ def competing_hypotheses(domain:, **)
70
+ hypotheses = hypothesis_engine.competing_hypotheses(domain: domain)
71
+ Legion::Logging.debug "[hypothesis_testing] competing count=#{hypotheses.size} domain=#{domain}"
72
+ {
73
+ domain: domain,
74
+ count: hypotheses.size,
75
+ hypotheses: hypotheses.map(&:to_h)
76
+ }
77
+ end
78
+
79
+ def most_confident_hypotheses(limit: 5, **)
80
+ hypotheses = hypothesis_engine.most_confident(limit: limit)
81
+ Legion::Logging.debug "[hypothesis_testing] most_confident count=#{hypotheses.size}"
82
+ {
83
+ count: hypotheses.size,
84
+ hypotheses: hypotheses.map(&:to_h)
85
+ }
86
+ end
87
+
88
+ def hypothesis_report(**)
89
+ report = hypothesis_engine.hypothesis_report
90
+ Legion::Logging.debug "[hypothesis_testing] report total=#{report[:total]} " \
91
+ "confirmation_rate=#{report[:confirmation_rate].round(4)}"
92
+ report
93
+ end
94
+
95
+ def get_hypothesis(hypothesis_id:, **)
96
+ h = hypothesis_engine.hypotheses[hypothesis_id]
97
+ return { found: false } unless h
98
+
99
+ { found: true, hypothesis: h.to_h }
100
+ end
101
+
102
+ private
103
+
104
+ def hypothesis_engine
105
+ @hypothesis_engine ||= Helpers::HypothesisEngine.new
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module HypothesisTesting
6
+ VERSION = '0.1.0'
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/hypothesis_testing/version'
4
+ require 'legion/extensions/hypothesis_testing/helpers/constants'
5
+ require 'legion/extensions/hypothesis_testing/helpers/hypothesis'
6
+ require 'legion/extensions/hypothesis_testing/helpers/hypothesis_engine'
7
+ require 'legion/extensions/hypothesis_testing/runners/hypothesis_testing'
8
+
9
+ module Legion
10
+ module Extensions
11
+ module HypothesisTesting
12
+ extend Legion::Extensions::Core if Legion::Extensions.const_defined? :Core
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/hypothesis_testing/helpers/constants'
4
+
5
+ RSpec.describe Legion::Extensions::HypothesisTesting::Helpers::Constants do
6
+ it 'defines MAX_HYPOTHESES as 300' do
7
+ expect(described_class::MAX_HYPOTHESES).to eq(300)
8
+ end
9
+
10
+ it 'defines CONFIRMATION_THRESHOLD as 0.8' do
11
+ expect(described_class::CONFIRMATION_THRESHOLD).to eq(0.8)
12
+ end
13
+
14
+ it 'defines DISCONFIRMATION_THRESHOLD as 0.2' do
15
+ expect(described_class::DISCONFIRMATION_THRESHOLD).to eq(0.2)
16
+ end
17
+
18
+ it 'defines EVIDENCE_WEIGHT as 0.1' do
19
+ expect(described_class::EVIDENCE_WEIGHT).to eq(0.1)
20
+ end
21
+
22
+ it 'defines PRIOR_DEFAULT as 0.5' do
23
+ expect(described_class::PRIOR_DEFAULT).to eq(0.5)
24
+ end
25
+
26
+ it 'defines all five STATUS_LABELS keys' do
27
+ expect(described_class::STATUS_LABELS.keys).to match_array(%i[proposed testing confirmed disconfirmed inconclusive])
28
+ end
29
+
30
+ it 'defines five CONFIDENCE_LABELS entries' do
31
+ expect(described_class::CONFIDENCE_LABELS.size).to eq(5)
32
+ end
33
+
34
+ it 'includes certain, confident, leaning, uncertain, agnostic labels' do
35
+ labels = described_class::CONFIDENCE_LABELS.map { |_, label| label }
36
+ expect(labels).to match_array(%w[certain confident leaning uncertain agnostic])
37
+ end
38
+ end
@@ -0,0 +1,182 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/hypothesis_testing/helpers/constants'
4
+ require 'legion/extensions/hypothesis_testing/helpers/hypothesis'
5
+ require 'legion/extensions/hypothesis_testing/helpers/hypothesis_engine'
6
+
7
+ RSpec.describe Legion::Extensions::HypothesisTesting::Helpers::HypothesisEngine do
8
+ subject(:engine) { described_class.new }
9
+
10
+ describe '#propose' do
11
+ it 'returns a Hypothesis object' do
12
+ h = engine.propose(description: 'test', domain: 'logic')
13
+ expect(h).to be_a(Legion::Extensions::HypothesisTesting::Helpers::Hypothesis)
14
+ end
15
+
16
+ it 'stores the hypothesis' do
17
+ h = engine.propose(description: 'stored')
18
+ expect(engine.hypotheses[h.id]).to eq(h)
19
+ end
20
+
21
+ it 'assigns the given domain' do
22
+ h = engine.propose(description: 'domain test', domain: 'physics')
23
+ expect(h.domain).to eq('physics')
24
+ end
25
+
26
+ it 'uses default domain general when not specified' do
27
+ h = engine.propose(description: 'default domain')
28
+ expect(h.domain).to eq('general')
29
+ end
30
+
31
+ it 'assigns the given prior' do
32
+ h = engine.propose(description: 'prior test', prior: 0.3)
33
+ expect(h.prior).to eq(0.3)
34
+ end
35
+
36
+ it 'evicts oldest when MAX_HYPOTHESES is reached' do
37
+ max = Legion::Extensions::HypothesisTesting::Helpers::Constants::MAX_HYPOTHESES
38
+ max.times { |i| engine.propose(description: "h#{i}") }
39
+ expect(engine.hypotheses.size).to eq(max)
40
+ oldest_id = engine.hypotheses.values.min_by(&:created_at).id
41
+ engine.propose(description: 'overflow')
42
+ expect(engine.hypotheses.key?(oldest_id)).to be false
43
+ end
44
+ end
45
+
46
+ describe '#test_hypothesis' do
47
+ it 'updates posterior when hypothesis exists' do
48
+ h = engine.propose(description: 'testable', prior: 0.5)
49
+ prior = h.posterior
50
+ engine.test_hypothesis(hypothesis_id: h.id, evidence_strength: 1.0, supporting: true)
51
+ expect(h.posterior).to be > prior
52
+ end
53
+
54
+ it 'returns nil for unknown hypothesis_id' do
55
+ result = engine.test_hypothesis(hypothesis_id: 'nonexistent', evidence_strength: 0.5, supporting: true)
56
+ expect(result).to be_nil
57
+ end
58
+
59
+ it 'auto-confirms when posterior crosses CONFIRMATION_THRESHOLD' do
60
+ h = engine.propose(description: 'high prior', prior: 0.79)
61
+ engine.test_hypothesis(hypothesis_id: h.id, evidence_strength: 1.0, supporting: true)
62
+ expect(h.status).to eq(:confirmed)
63
+ end
64
+
65
+ it 'auto-disconfirms when posterior falls below DISCONFIRMATION_THRESHOLD' do
66
+ h = engine.propose(description: 'low prior', prior: 0.21)
67
+ engine.test_hypothesis(hypothesis_id: h.id, evidence_strength: 1.0, supporting: false)
68
+ expect(h.status).to eq(:disconfirmed)
69
+ end
70
+ end
71
+
72
+ describe '#evaluate' do
73
+ it 'returns nil for unknown hypothesis_id' do
74
+ expect(engine.evaluate('nonexistent')).to be_nil
75
+ end
76
+
77
+ it 'confirms when posterior >= CONFIRMATION_THRESHOLD' do
78
+ h = engine.propose(description: 'evaluate confirm', prior: 0.9)
79
+ engine.evaluate(h.id)
80
+ expect(h.status).to eq(:confirmed)
81
+ end
82
+
83
+ it 'disconfirms when posterior <= DISCONFIRMATION_THRESHOLD' do
84
+ h = engine.propose(description: 'evaluate disconfirm', prior: 0.1)
85
+ engine.evaluate(h.id)
86
+ expect(h.status).to eq(:disconfirmed)
87
+ end
88
+
89
+ it 'does not re-evaluate an already confirmed hypothesis' do
90
+ h = engine.propose(description: 'already confirmed', prior: 0.5)
91
+ h.confirm!
92
+ engine.evaluate(h.id)
93
+ expect(h.status).to eq(:confirmed)
94
+ end
95
+ end
96
+
97
+ describe '#competing_hypotheses' do
98
+ it 'returns all hypotheses in the given domain' do
99
+ engine.propose(description: 'h1', domain: 'physics')
100
+ engine.propose(description: 'h2', domain: 'physics')
101
+ engine.propose(description: 'h3', domain: 'chemistry')
102
+ result = engine.competing_hypotheses(domain: 'physics')
103
+ expect(result.size).to eq(2)
104
+ end
105
+
106
+ it 'returns an empty array when no hypotheses match the domain' do
107
+ engine.propose(description: 'other', domain: 'biology')
108
+ expect(engine.competing_hypotheses(domain: 'astronomy')).to be_empty
109
+ end
110
+ end
111
+
112
+ describe '#most_confident' do
113
+ it 'returns hypotheses sorted by descending posterior' do
114
+ h1 = engine.propose(description: 'low', prior: 0.2)
115
+ h2 = engine.propose(description: 'high', prior: 0.9)
116
+ h3 = engine.propose(description: 'mid', prior: 0.5)
117
+ result = engine.most_confident(limit: 3)
118
+ expect(result.map(&:id)).to eq([h2.id, h3.id, h1.id])
119
+ end
120
+
121
+ it 'respects the limit parameter' do
122
+ 5.times { |i| engine.propose(description: "h#{i}", prior: i * 0.1) }
123
+ expect(engine.most_confident(limit: 3).size).to eq(3)
124
+ end
125
+ end
126
+
127
+ describe '#confirmation_rate' do
128
+ it 'returns 0.0 when no hypotheses have been resolved' do
129
+ engine.propose(description: 'unresolved')
130
+ expect(engine.confirmation_rate).to eq(0.0)
131
+ end
132
+
133
+ it 'computes rate correctly with mixed outcomes' do
134
+ h1 = engine.propose(description: 'h1', prior: 0.9)
135
+ h1.confirm!
136
+ h2 = engine.propose(description: 'h2', prior: 0.1)
137
+ h2.disconfirm!
138
+ expect(engine.confirmation_rate).to eq(0.5)
139
+ end
140
+
141
+ it 'returns 1.0 when all resolved hypotheses are confirmed' do
142
+ h = engine.propose(description: 'confirmed', prior: 0.9)
143
+ h.confirm!
144
+ expect(engine.confirmation_rate).to eq(1.0)
145
+ end
146
+ end
147
+
148
+ describe '#hypothesis_report' do
149
+ it 'includes total, by_status, confirmation_rate, most_confident' do
150
+ engine.propose(description: 'a')
151
+ engine.propose(description: 'b')
152
+ report = engine.hypothesis_report
153
+ expect(report.keys).to include(:total, :by_status, :confirmation_rate, :most_confident)
154
+ end
155
+
156
+ it 'reports correct total count' do
157
+ 3.times { |i| engine.propose(description: "h#{i}") }
158
+ expect(engine.hypothesis_report[:total]).to eq(3)
159
+ end
160
+
161
+ it 'limits most_confident to 3 entries' do
162
+ 5.times { |i| engine.propose(description: "h#{i}") }
163
+ expect(engine.hypothesis_report[:most_confident].size).to be <= 3
164
+ end
165
+ end
166
+
167
+ describe '#to_h' do
168
+ it 'includes hypotheses array, confirmation_rate, and total' do
169
+ engine.propose(description: 'serialized')
170
+ result = engine.to_h
171
+ expect(result.keys).to include(:hypotheses, :confirmation_rate, :total)
172
+ end
173
+
174
+ it 'returns each hypothesis as a hash' do
175
+ engine.propose(description: 'one')
176
+ engine.to_h[:hypotheses].each do |h|
177
+ expect(h).to be_a(Hash)
178
+ expect(h.keys).to include(:id, :description, :status)
179
+ end
180
+ end
181
+ end
182
+ end
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/hypothesis_testing/helpers/constants'
4
+ require 'legion/extensions/hypothesis_testing/helpers/hypothesis'
5
+
6
+ RSpec.describe Legion::Extensions::HypothesisTesting::Helpers::Hypothesis do
7
+ subject(:hypothesis) { described_class.new(description: 'test hypothesis', domain: 'logic') }
8
+
9
+ describe '#initialize' do
10
+ it 'assigns a uuid id' do
11
+ expect(hypothesis.id).to match(/\A[0-9a-f-]{36}\z/)
12
+ end
13
+
14
+ it 'sets description' do
15
+ expect(hypothesis.description).to eq('test hypothesis')
16
+ end
17
+
18
+ it 'sets domain' do
19
+ expect(hypothesis.domain).to eq('logic')
20
+ end
21
+
22
+ it 'defaults prior to PRIOR_DEFAULT' do
23
+ h = described_class.new(description: 'default prior')
24
+ expect(h.prior).to eq(Legion::Extensions::HypothesisTesting::Helpers::Constants::PRIOR_DEFAULT)
25
+ end
26
+
27
+ it 'sets posterior to prior at creation' do
28
+ h = described_class.new(description: 'check posterior', prior: 0.3)
29
+ expect(h.posterior).to eq(0.3)
30
+ end
31
+
32
+ it 'starts with evidence_count of 0' do
33
+ expect(hypothesis.evidence_count).to eq(0)
34
+ end
35
+
36
+ it 'starts with status :proposed' do
37
+ expect(hypothesis.status).to eq(:proposed)
38
+ end
39
+
40
+ it 'clamps prior above 1.0 to 1.0' do
41
+ h = described_class.new(description: 'high', prior: 1.5)
42
+ expect(h.prior).to eq(1.0)
43
+ end
44
+
45
+ it 'clamps prior below 0.0 to 0.0' do
46
+ h = described_class.new(description: 'low', prior: -0.1)
47
+ expect(h.prior).to eq(0.0)
48
+ end
49
+ end
50
+
51
+ describe '#update_posterior!' do
52
+ it 'increases posterior with supporting evidence' do
53
+ before = hypothesis.posterior
54
+ hypothesis.update_posterior!(evidence_strength: 1.0, supporting: true)
55
+ expect(hypothesis.posterior).to be > before
56
+ end
57
+
58
+ it 'decreases posterior with contradicting evidence' do
59
+ h = described_class.new(description: 'contra', prior: 0.8)
60
+ h.update_posterior!(evidence_strength: 1.0, supporting: false)
61
+ expect(h.posterior).to be < 0.8
62
+ end
63
+
64
+ it 'transitions status to :testing' do
65
+ hypothesis.update_posterior!(evidence_strength: 0.5, supporting: true)
66
+ expect(hypothesis.status).to eq(:testing)
67
+ end
68
+
69
+ it 'increments evidence_count' do
70
+ hypothesis.update_posterior!(evidence_strength: 0.5, supporting: true)
71
+ expect(hypothesis.evidence_count).to eq(1)
72
+ end
73
+
74
+ it 'keeps posterior within [0.0, 1.0]' do
75
+ 10.times { hypothesis.update_posterior!(evidence_strength: 1.0, supporting: true) }
76
+ expect(hypothesis.posterior).to be <= 1.0
77
+ end
78
+
79
+ it 'does not update a confirmed hypothesis' do
80
+ hypothesis.confirm!
81
+ prior_posterior = hypothesis.posterior
82
+ hypothesis.update_posterior!(evidence_strength: 1.0, supporting: false)
83
+ expect(hypothesis.posterior).to eq(prior_posterior)
84
+ end
85
+
86
+ it 'does not update a disconfirmed hypothesis' do
87
+ hypothesis.disconfirm!
88
+ prior_posterior = hypothesis.posterior
89
+ hypothesis.update_posterior!(evidence_strength: 1.0, supporting: true)
90
+ expect(hypothesis.posterior).to eq(prior_posterior)
91
+ end
92
+
93
+ it 'clamps evidence_strength above 1.0' do
94
+ hypothesis.update_posterior!(evidence_strength: 5.0, supporting: true)
95
+ expect(hypothesis.posterior).to be <= 1.0
96
+ end
97
+
98
+ it 'rounds posterior to 10 decimal places' do
99
+ hypothesis.update_posterior!(evidence_strength: 0.7, supporting: true)
100
+ expect(hypothesis.posterior.to_s.split('.').last.length).to be <= 10
101
+ end
102
+ end
103
+
104
+ describe '#confirm!' do
105
+ it 'sets status to :confirmed' do
106
+ hypothesis.confirm!
107
+ expect(hypothesis.status).to eq(:confirmed)
108
+ end
109
+
110
+ it 'raises posterior to at least CONFIRMATION_THRESHOLD' do
111
+ h = described_class.new(description: 'near confirm', prior: 0.75)
112
+ h.confirm!
113
+ expect(h.posterior).to be >= Legion::Extensions::HypothesisTesting::Helpers::Constants::CONFIRMATION_THRESHOLD
114
+ end
115
+ end
116
+
117
+ describe '#disconfirm!' do
118
+ it 'sets status to :disconfirmed' do
119
+ hypothesis.disconfirm!
120
+ expect(hypothesis.status).to eq(:disconfirmed)
121
+ end
122
+
123
+ it 'lowers posterior to at most DISCONFIRMATION_THRESHOLD' do
124
+ h = described_class.new(description: 'near disconfirm', prior: 0.25)
125
+ h.disconfirm!
126
+ expect(h.posterior).to be <= Legion::Extensions::HypothesisTesting::Helpers::Constants::DISCONFIRMATION_THRESHOLD
127
+ end
128
+ end
129
+
130
+ describe '#confidence_label' do
131
+ it 'returns certain for posterior >= 0.9' do
132
+ h = described_class.new(description: 'certain', prior: 0.95)
133
+ expect(h.confidence_label).to eq('certain')
134
+ end
135
+
136
+ it 'returns confident for posterior in [0.7, 0.9)' do
137
+ h = described_class.new(description: 'confident', prior: 0.8)
138
+ expect(h.confidence_label).to eq('confident')
139
+ end
140
+
141
+ it 'returns leaning for posterior in [0.5, 0.7)' do
142
+ h = described_class.new(description: 'leaning', prior: 0.6)
143
+ expect(h.confidence_label).to eq('leaning')
144
+ end
145
+
146
+ it 'returns uncertain for posterior in [0.3, 0.5)' do
147
+ h = described_class.new(description: 'uncertain', prior: 0.4)
148
+ expect(h.confidence_label).to eq('uncertain')
149
+ end
150
+
151
+ it 'returns agnostic for posterior in [0.0, 0.3)' do
152
+ h = described_class.new(description: 'agnostic', prior: 0.1)
153
+ expect(h.confidence_label).to eq('agnostic')
154
+ end
155
+ end
156
+
157
+ describe '#to_h' do
158
+ it 'returns a hash with all required keys' do
159
+ result = hypothesis.to_h
160
+ expect(result.keys).to include(:id, :description, :domain, :prior, :posterior,
161
+ :evidence_count, :status, :confidence_label, :created_at)
162
+ end
163
+
164
+ it 'returns correct id' do
165
+ expect(hypothesis.to_h[:id]).to eq(hypothesis.id)
166
+ end
167
+
168
+ it 'returns correct status' do
169
+ expect(hypothesis.to_h[:status]).to eq(:proposed)
170
+ end
171
+ end
172
+ end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/hypothesis_testing/client'
4
+
5
+ RSpec.describe Legion::Extensions::HypothesisTesting::Runners::HypothesisTesting do
6
+ let(:client) { Legion::Extensions::HypothesisTesting::Client.new }
7
+
8
+ describe '#propose_hypothesis' do
9
+ it 'returns a hypothesis_id uuid' do
10
+ result = client.propose_hypothesis(description: 'water boils at 100C')
11
+ expect(result[:hypothesis_id]).to match(/\A[0-9a-f-]{36}\z/)
12
+ end
13
+
14
+ it 'returns the description' do
15
+ result = client.propose_hypothesis(description: 'gravity exists')
16
+ expect(result[:description]).to eq('gravity exists')
17
+ end
18
+
19
+ it 'returns the domain' do
20
+ result = client.propose_hypothesis(description: 'test', domain: 'physics')
21
+ expect(result[:domain]).to eq('physics')
22
+ end
23
+
24
+ it 'defaults domain to general' do
25
+ result = client.propose_hypothesis(description: 'test')
26
+ expect(result[:domain]).to eq('general')
27
+ end
28
+
29
+ it 'returns status :proposed' do
30
+ result = client.propose_hypothesis(description: 'test')
31
+ expect(result[:status]).to eq(:proposed)
32
+ end
33
+
34
+ it 'returns a confidence_label' do
35
+ result = client.propose_hypothesis(description: 'test', prior: 0.9)
36
+ expect(result[:confidence_label]).to eq('certain')
37
+ end
38
+
39
+ it 'sets prior and posterior to the given prior' do
40
+ result = client.propose_hypothesis(description: 'test', prior: 0.7)
41
+ expect(result[:prior]).to eq(0.7)
42
+ expect(result[:posterior]).to eq(0.7)
43
+ end
44
+ end
45
+
46
+ describe '#test_hypothesis' do
47
+ let(:proposed) { client.propose_hypothesis(description: 'testable') }
48
+
49
+ it 'returns tested: true for a known hypothesis' do
50
+ result = client.test_hypothesis(hypothesis_id: proposed[:hypothesis_id], evidence_strength: 0.5, supporting: true)
51
+ expect(result[:tested]).to be true
52
+ end
53
+
54
+ it 'returns tested: false for an unknown id' do
55
+ result = client.test_hypothesis(hypothesis_id: 'missing', evidence_strength: 0.5, supporting: true)
56
+ expect(result[:tested]).to be false
57
+ expect(result[:reason]).to eq(:not_found)
58
+ end
59
+
60
+ it 'includes updated posterior' do
61
+ result = client.test_hypothesis(hypothesis_id: proposed[:hypothesis_id], evidence_strength: 1.0, supporting: true)
62
+ expect(result[:posterior]).to be > proposed[:posterior]
63
+ end
64
+
65
+ it 'includes updated evidence_count' do
66
+ result = client.test_hypothesis(hypothesis_id: proposed[:hypothesis_id], evidence_strength: 0.5, supporting: true)
67
+ expect(result[:evidence_count]).to eq(1)
68
+ end
69
+
70
+ it 'includes status' do
71
+ result = client.test_hypothesis(hypothesis_id: proposed[:hypothesis_id], evidence_strength: 0.5, supporting: true)
72
+ expect(result.key?(:status)).to be true
73
+ end
74
+
75
+ it 'returns :confirmed status when posterior crosses threshold' do
76
+ h = client.propose_hypothesis(description: 'high prior', prior: 0.79)
77
+ result = client.test_hypothesis(hypothesis_id: h[:hypothesis_id], evidence_strength: 1.0, supporting: true)
78
+ expect(result[:status]).to eq(:confirmed)
79
+ end
80
+ end
81
+
82
+ describe '#evaluate_hypothesis' do
83
+ it 'returns found: false for unknown id' do
84
+ result = client.evaluate_hypothesis(hypothesis_id: 'ghost')
85
+ expect(result[:found]).to be false
86
+ end
87
+
88
+ it 'returns found: true for known hypothesis' do
89
+ h = client.propose_hypothesis(description: 'evaluatable')
90
+ result = client.evaluate_hypothesis(hypothesis_id: h[:hypothesis_id])
91
+ expect(result[:found]).to be true
92
+ end
93
+
94
+ it 'confirms hypothesis when posterior is above threshold' do
95
+ h = client.propose_hypothesis(description: 'high', prior: 0.9)
96
+ result = client.evaluate_hypothesis(hypothesis_id: h[:hypothesis_id])
97
+ expect(result[:status]).to eq(:confirmed)
98
+ end
99
+ end
100
+
101
+ describe '#competing_hypotheses' do
102
+ it 'returns hypotheses in the given domain' do
103
+ client.propose_hypothesis(description: 'h1', domain: 'biology')
104
+ client.propose_hypothesis(description: 'h2', domain: 'biology')
105
+ client.propose_hypothesis(description: 'h3', domain: 'chemistry')
106
+ result = client.competing_hypotheses(domain: 'biology')
107
+ expect(result[:count]).to eq(2)
108
+ expect(result[:domain]).to eq('biology')
109
+ end
110
+
111
+ it 'returns empty list for unrepresented domain' do
112
+ result = client.competing_hypotheses(domain: 'astrology')
113
+ expect(result[:count]).to eq(0)
114
+ expect(result[:hypotheses]).to be_empty
115
+ end
116
+ end
117
+
118
+ describe '#most_confident_hypotheses' do
119
+ it 'returns hypotheses sorted by descending posterior' do
120
+ client.propose_hypothesis(description: 'low', prior: 0.2)
121
+ client.propose_hypothesis(description: 'high', prior: 0.9)
122
+ result = client.most_confident_hypotheses(limit: 2)
123
+ expect(result[:hypotheses].first[:posterior]).to be >= result[:hypotheses].last[:posterior]
124
+ end
125
+
126
+ it 'respects the limit' do
127
+ 5.times { |i| client.propose_hypothesis(description: "h#{i}") }
128
+ result = client.most_confident_hypotheses(limit: 3)
129
+ expect(result[:count]).to be <= 3
130
+ end
131
+ end
132
+
133
+ describe '#hypothesis_report' do
134
+ it 'includes total, by_status, confirmation_rate, most_confident' do
135
+ client.propose_hypothesis(description: 'reported')
136
+ result = client.hypothesis_report
137
+ expect(result.keys).to include(:total, :by_status, :confirmation_rate, :most_confident)
138
+ end
139
+
140
+ it 'counts total correctly' do
141
+ 3.times { |i| client.propose_hypothesis(description: "h#{i}") }
142
+ expect(client.hypothesis_report[:total]).to eq(3)
143
+ end
144
+ end
145
+
146
+ describe '#get_hypothesis' do
147
+ it 'returns found: false for unknown id' do
148
+ result = client.get_hypothesis(hypothesis_id: 'nope')
149
+ expect(result[:found]).to be false
150
+ end
151
+
152
+ it 'returns found: true and hypothesis hash for known id' do
153
+ h = client.propose_hypothesis(description: 'gettable')
154
+ result = client.get_hypothesis(hypothesis_id: h[:hypothesis_id])
155
+ expect(result[:found]).to be true
156
+ expect(result[:hypothesis][:id]).to eq(h[:hypothesis_id])
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/hypothesis_testing/client'
4
+
5
+ RSpec.describe Legion::Extensions::HypothesisTesting::Client do
6
+ it 'responds to all runner methods' do
7
+ client = described_class.new
8
+ expect(client).to respond_to(:propose_hypothesis)
9
+ expect(client).to respond_to(:test_hypothesis)
10
+ expect(client).to respond_to(:evaluate_hypothesis)
11
+ expect(client).to respond_to(:competing_hypotheses)
12
+ expect(client).to respond_to(:most_confident_hypotheses)
13
+ expect(client).to respond_to(:hypothesis_report)
14
+ expect(client).to respond_to(:get_hypothesis)
15
+ end
16
+ 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/hypothesis_testing'
15
+
16
+ RSpec.configure do |config|
17
+ config.example_status_persistence_file_path = '.rspec_status'
18
+ config.disable_monkey_patching!
19
+ config.expect_with(:rspec) { |c| c.syntax = :expect }
20
+ end
metadata ADDED
@@ -0,0 +1,76 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: lex-hypothesis-testing
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: Scientific hypothesis testing cycle for brain-modeled agentic AI — Bayesian
27
+ evidence accumulation, lifecycle management, competing hypotheses
28
+ email:
29
+ - matthewdiverson@gmail.com
30
+ executables: []
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - Gemfile
35
+ - lex-hypothesis-testing.gemspec
36
+ - lib/legion/extensions/hypothesis_testing.rb
37
+ - lib/legion/extensions/hypothesis_testing/client.rb
38
+ - lib/legion/extensions/hypothesis_testing/helpers/constants.rb
39
+ - lib/legion/extensions/hypothesis_testing/helpers/hypothesis.rb
40
+ - lib/legion/extensions/hypothesis_testing/helpers/hypothesis_engine.rb
41
+ - lib/legion/extensions/hypothesis_testing/runners/hypothesis_testing.rb
42
+ - lib/legion/extensions/hypothesis_testing/version.rb
43
+ - spec/legion/extensions/hypothesis_testing/helpers/constants_spec.rb
44
+ - spec/legion/extensions/hypothesis_testing/helpers/hypothesis_engine_spec.rb
45
+ - spec/legion/extensions/hypothesis_testing/helpers/hypothesis_spec.rb
46
+ - spec/legion/extensions/hypothesis_testing/runners/hypothesis_testing_spec.rb
47
+ - spec/legion/extensions/hypothesis_testing_spec.rb
48
+ - spec/spec_helper.rb
49
+ homepage: https://github.com/LegionIO/lex-hypothesis-testing
50
+ licenses:
51
+ - MIT
52
+ metadata:
53
+ homepage_uri: https://github.com/LegionIO/lex-hypothesis-testing
54
+ source_code_uri: https://github.com/LegionIO/lex-hypothesis-testing
55
+ documentation_uri: https://github.com/LegionIO/lex-hypothesis-testing
56
+ changelog_uri: https://github.com/LegionIO/lex-hypothesis-testing
57
+ bug_tracker_uri: https://github.com/LegionIO/lex-hypothesis-testing/issues
58
+ rubygems_mfa_required: 'true'
59
+ rdoc_options: []
60
+ require_paths:
61
+ - lib
62
+ required_ruby_version: !ruby/object:Gem::Requirement
63
+ requirements:
64
+ - - ">="
65
+ - !ruby/object:Gem::Version
66
+ version: '3.4'
67
+ required_rubygems_version: !ruby/object:Gem::Requirement
68
+ requirements:
69
+ - - ">="
70
+ - !ruby/object:Gem::Version
71
+ version: '0'
72
+ requirements: []
73
+ rubygems_version: 3.6.9
74
+ specification_version: 4
75
+ summary: LEX Hypothesis Testing
76
+ test_files: []