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 +7 -0
- data/Gemfile +11 -0
- data/lex-hypothesis-testing.gemspec +30 -0
- data/lib/legion/extensions/hypothesis_testing/client.rb +24 -0
- data/lib/legion/extensions/hypothesis_testing/helpers/constants.rb +33 -0
- data/lib/legion/extensions/hypothesis_testing/helpers/hypothesis.rb +79 -0
- data/lib/legion/extensions/hypothesis_testing/helpers/hypothesis_engine.rb +93 -0
- data/lib/legion/extensions/hypothesis_testing/runners/hypothesis_testing.rb +111 -0
- data/lib/legion/extensions/hypothesis_testing/version.rb +9 -0
- data/lib/legion/extensions/hypothesis_testing.rb +15 -0
- data/spec/legion/extensions/hypothesis_testing/helpers/constants_spec.rb +38 -0
- data/spec/legion/extensions/hypothesis_testing/helpers/hypothesis_engine_spec.rb +182 -0
- data/spec/legion/extensions/hypothesis_testing/helpers/hypothesis_spec.rb +172 -0
- data/spec/legion/extensions/hypothesis_testing/runners/hypothesis_testing_spec.rb +159 -0
- data/spec/legion/extensions/hypothesis_testing_spec.rb +16 -0
- data/spec/spec_helper.rb +20 -0
- metadata +76 -0
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,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,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
|
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/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: []
|