lex-predictive-processing 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-predictive-processing.gemspec +30 -0
- data/lib/legion/extensions/predictive_processing/client.rb +24 -0
- data/lib/legion/extensions/predictive_processing/helpers/constants.rb +31 -0
- data/lib/legion/extensions/predictive_processing/helpers/generative_model.rb +138 -0
- data/lib/legion/extensions/predictive_processing/helpers/predictive_processor.rb +125 -0
- data/lib/legion/extensions/predictive_processing/runners/predictive_processing.rb +100 -0
- data/lib/legion/extensions/predictive_processing/version.rb +9 -0
- data/lib/legion/extensions/predictive_processing.rb +15 -0
- data/spec/legion/extensions/predictive_processing/client_spec.rb +82 -0
- data/spec/legion/extensions/predictive_processing/helpers/generative_model_spec.rb +220 -0
- data/spec/legion/extensions/predictive_processing/helpers/predictive_processor_spec.rb +206 -0
- data/spec/legion/extensions/predictive_processing/runners/predictive_processing_spec.rb +213 -0
- data/spec/spec_helper.rb +29 -0
- metadata +76 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 0b2a81f552ab46d8107dcadb31234489978d943358698284842e2513c06ee7f4
|
|
4
|
+
data.tar.gz: 83fdf31810c065a1b6b48db9572807362fb46a34ca14a1b4d86e028cd6d8085b
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: c0710af7f0b991c387797a3ed494232eb3a7a96d687dc0a362b8a99f3e58321e912305a405e58b834c3c4afc675ddb3c81e634c0456499ba2699dcb841055cd1
|
|
7
|
+
data.tar.gz: 33ac74d6a364efdac1d5fe23d31991090b14bc9a14e2bce7501b1d17ec717a41aff8d6d0b994b82b4c06dd6053625ad78326b62fc0638327796da0dc30bcdfab
|
data/Gemfile
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'lib/legion/extensions/predictive_processing/version'
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = 'lex-predictive-processing'
|
|
7
|
+
spec.version = Legion::Extensions::PredictiveProcessing::VERSION
|
|
8
|
+
spec.authors = ['Esity']
|
|
9
|
+
spec.email = ['matthewdiverson@gmail.com']
|
|
10
|
+
|
|
11
|
+
spec.summary = 'LEX Predictive Processing'
|
|
12
|
+
spec.description = "Andy Clark's Predictive Processing / Active Inference for brain-modeled agentic AI: " \
|
|
13
|
+
'generative models, precision-weighting, active inference, and free energy minimization'
|
|
14
|
+
spec.homepage = 'https://github.com/LegionIO/lex-predictive-processing'
|
|
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-predictive-processing'
|
|
20
|
+
spec.metadata['documentation_uri'] = 'https://github.com/LegionIO/lex-predictive-processing'
|
|
21
|
+
spec.metadata['changelog_uri'] = 'https://github.com/LegionIO/lex-predictive-processing'
|
|
22
|
+
spec.metadata['bug_tracker_uri'] = 'https://github.com/LegionIO/lex-predictive-processing/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-predictive-processing.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/predictive_processing/helpers/constants'
|
|
4
|
+
require 'legion/extensions/predictive_processing/helpers/generative_model'
|
|
5
|
+
require 'legion/extensions/predictive_processing/helpers/predictive_processor'
|
|
6
|
+
require 'legion/extensions/predictive_processing/runners/predictive_processing'
|
|
7
|
+
|
|
8
|
+
module Legion
|
|
9
|
+
module Extensions
|
|
10
|
+
module PredictiveProcessing
|
|
11
|
+
class Client
|
|
12
|
+
include Runners::PredictiveProcessing
|
|
13
|
+
|
|
14
|
+
def initialize(processor: nil)
|
|
15
|
+
@default_processor = processor || Helpers::PredictiveProcessor.new
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
attr_reader :default_processor
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module PredictiveProcessing
|
|
6
|
+
module Helpers
|
|
7
|
+
module Constants
|
|
8
|
+
MAX_MODELS = 20
|
|
9
|
+
MAX_PREDICTIONS_PER_MODEL = 50
|
|
10
|
+
MAX_HISTORY = 200
|
|
11
|
+
DEFAULT_PRECISION = 0.5
|
|
12
|
+
PRECISION_FLOOR = 0.05
|
|
13
|
+
PRECISION_DECAY = 0.02
|
|
14
|
+
MODEL_CONFIDENCE_FLOOR = 0.1
|
|
15
|
+
FREE_ENERGY_THRESHOLD = 0.7
|
|
16
|
+
ACTIVE_INFERENCE_THRESHOLD = 0.5
|
|
17
|
+
LEARNING_RATE = 0.1
|
|
18
|
+
INFERENCE_MODES = %i[perceptual active hybrid].freeze
|
|
19
|
+
MODEL_STATES = %i[stable updating exploring surprised].freeze
|
|
20
|
+
PRECISION_LABELS = {
|
|
21
|
+
(0.8..) => :certain,
|
|
22
|
+
(0.6...0.8) => :confident,
|
|
23
|
+
(0.4...0.6) => :uncertain,
|
|
24
|
+
(0.2...0.4) => :vague,
|
|
25
|
+
(..0.2) => :noise
|
|
26
|
+
}.freeze
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module Extensions
|
|
7
|
+
module PredictiveProcessing
|
|
8
|
+
module Helpers
|
|
9
|
+
class GenerativeModel
|
|
10
|
+
include Constants
|
|
11
|
+
|
|
12
|
+
attr_reader :id, :domain, :confidence, :precision, :prediction_error, :state,
|
|
13
|
+
:last_prediction, :created_at, :updated_at
|
|
14
|
+
|
|
15
|
+
def initialize(domain:)
|
|
16
|
+
@id = SecureRandom.uuid
|
|
17
|
+
@domain = domain
|
|
18
|
+
@confidence = DEFAULT_PRECISION
|
|
19
|
+
@precision = DEFAULT_PRECISION
|
|
20
|
+
@prediction_error = 0.0
|
|
21
|
+
@state = :stable
|
|
22
|
+
@history = []
|
|
23
|
+
@last_prediction = nil
|
|
24
|
+
@created_at = Time.now.utc
|
|
25
|
+
@updated_at = Time.now.utc
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def predict(context: {})
|
|
29
|
+
richness = context_richness(context)
|
|
30
|
+
expected_val = (confidence + richness).clamp(0.0, 1.0)
|
|
31
|
+
@last_prediction = {
|
|
32
|
+
expected_value: expected_val,
|
|
33
|
+
confidence: confidence,
|
|
34
|
+
precision: precision,
|
|
35
|
+
domain: @domain,
|
|
36
|
+
context_size: context.size,
|
|
37
|
+
predicted_at: Time.now.utc
|
|
38
|
+
}
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def observe(actual:, predicted:)
|
|
42
|
+
@prediction_error = compute_error(actual, predicted)
|
|
43
|
+
update_history(@prediction_error)
|
|
44
|
+
update_confidence
|
|
45
|
+
update_state
|
|
46
|
+
@updated_at = Time.now.utc
|
|
47
|
+
@prediction_error
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Free energy (surprise): high when errors are large relative to precision.
|
|
51
|
+
# Uses a formula that can exceed FREE_ENERGY_THRESHOLD (0.7) with sustained errors.
|
|
52
|
+
def free_energy
|
|
53
|
+
return 0.0 if @history.empty?
|
|
54
|
+
|
|
55
|
+
recent = @history.last(10)
|
|
56
|
+
avg_error = recent.sum.to_f / recent.size
|
|
57
|
+
raw = avg_error * (1.5 - (precision * 0.5))
|
|
58
|
+
raw.clamp(0.0, 1.0)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def update_model(error:)
|
|
62
|
+
adjustment = error * LEARNING_RATE * precision
|
|
63
|
+
@confidence = (@confidence - adjustment).clamp(MODEL_CONFIDENCE_FLOOR, 1.0)
|
|
64
|
+
@state = :updating
|
|
65
|
+
@updated_at = Time.now.utc
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def stable?
|
|
69
|
+
free_energy <= FREE_ENERGY_THRESHOLD
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def surprised?
|
|
73
|
+
free_energy > FREE_ENERGY_THRESHOLD
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def decay
|
|
77
|
+
@precision = [@precision - PRECISION_DECAY, PRECISION_FLOOR].max
|
|
78
|
+
@updated_at = Time.now.utc
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def precision_label
|
|
82
|
+
PRECISION_LABELS.find { |range, _label| range.cover?(precision) }&.last || :noise
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def to_h
|
|
86
|
+
{
|
|
87
|
+
id: @id,
|
|
88
|
+
domain: @domain,
|
|
89
|
+
confidence: confidence,
|
|
90
|
+
precision: precision,
|
|
91
|
+
prediction_error: @prediction_error,
|
|
92
|
+
free_energy: free_energy,
|
|
93
|
+
state: @state,
|
|
94
|
+
precision_label: precision_label,
|
|
95
|
+
stable: stable?,
|
|
96
|
+
surprised: surprised?,
|
|
97
|
+
history_size: @history.size,
|
|
98
|
+
created_at: @created_at,
|
|
99
|
+
updated_at: @updated_at
|
|
100
|
+
}
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
def context_richness(context)
|
|
106
|
+
[context.size * 0.02, 0.2].min
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def compute_error(actual, predicted)
|
|
110
|
+
return 0.0 unless actual.is_a?(Numeric) && predicted.is_a?(Numeric)
|
|
111
|
+
|
|
112
|
+
(actual - predicted).abs.clamp(0.0, 1.0)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def update_history(error)
|
|
116
|
+
@history << error
|
|
117
|
+
@history.shift while @history.size > MAX_HISTORY
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def update_confidence
|
|
121
|
+
delta = -@prediction_error * LEARNING_RATE
|
|
122
|
+
@confidence = (@confidence + delta).clamp(MODEL_CONFIDENCE_FLOOR, 1.0)
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def update_state
|
|
126
|
+
@state = if surprised?
|
|
127
|
+
:surprised
|
|
128
|
+
elsif @history.size < 5
|
|
129
|
+
:exploring
|
|
130
|
+
else
|
|
131
|
+
:stable
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module PredictiveProcessing
|
|
6
|
+
module Helpers
|
|
7
|
+
class PredictiveProcessor
|
|
8
|
+
include Constants
|
|
9
|
+
|
|
10
|
+
attr_reader :models
|
|
11
|
+
|
|
12
|
+
def initialize
|
|
13
|
+
@models = {}
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def add_model(domain:)
|
|
17
|
+
return { added: false, reason: :limit_reached } if @models.size >= MAX_MODELS
|
|
18
|
+
return { added: false, reason: :already_exists } if @models.key?(domain)
|
|
19
|
+
|
|
20
|
+
model = GenerativeModel.new(domain: domain)
|
|
21
|
+
@models[domain] = model
|
|
22
|
+
{ added: true, domain: domain, model_id: model.id }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def predict(domain:, context: {})
|
|
26
|
+
model = find_or_create(domain)
|
|
27
|
+
model.predict(context: context)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def observe(domain:, actual:, predicted:)
|
|
31
|
+
model = @models[domain]
|
|
32
|
+
return { observed: false, reason: :domain_not_found } unless model
|
|
33
|
+
|
|
34
|
+
error = model.observe(actual: actual, predicted: predicted)
|
|
35
|
+
mode = inference_mode(domain)
|
|
36
|
+
|
|
37
|
+
model.update_model(error: error) if %i[perceptual hybrid].include?(mode)
|
|
38
|
+
|
|
39
|
+
{
|
|
40
|
+
observed: true,
|
|
41
|
+
domain: domain,
|
|
42
|
+
prediction_error: error,
|
|
43
|
+
inference_mode: mode,
|
|
44
|
+
free_energy: model.free_energy,
|
|
45
|
+
state: model.state
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def inference_mode(domain)
|
|
50
|
+
model = @models[domain]
|
|
51
|
+
return :perceptual unless model
|
|
52
|
+
|
|
53
|
+
fe = model.free_energy
|
|
54
|
+
determine_mode(fe, model.precision)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def free_energy_for(domain)
|
|
58
|
+
model = @models[domain]
|
|
59
|
+
model&.free_energy
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def global_free_energy
|
|
63
|
+
return 0.0 if @models.empty?
|
|
64
|
+
|
|
65
|
+
total = @models.values.sum(&:free_energy)
|
|
66
|
+
total / @models.size
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def precision_weight(domain)
|
|
70
|
+
model = @models[domain]
|
|
71
|
+
model ? model.precision : DEFAULT_PRECISION
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def models_needing_update
|
|
75
|
+
@models.select { |_d, m| m.surprised? }.transform_values(&:to_h)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def stable_models
|
|
79
|
+
@models.select { |_d, m| m.stable? }.transform_values(&:to_h)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def active_inference_candidates
|
|
83
|
+
@models.select { |_d, m| m.free_energy > ACTIVE_INFERENCE_THRESHOLD }
|
|
84
|
+
.keys
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def tick
|
|
88
|
+
@models.each_value(&:decay)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def to_h
|
|
92
|
+
{
|
|
93
|
+
model_count: @models.size,
|
|
94
|
+
global_free_energy: global_free_energy,
|
|
95
|
+
models_needing_update: models_needing_update.size,
|
|
96
|
+
stable_model_count: stable_models.size,
|
|
97
|
+
active_inference_domains: active_inference_candidates.size,
|
|
98
|
+
models: @models.transform_values(&:to_h)
|
|
99
|
+
}
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
def find_or_create(domain)
|
|
105
|
+
@models[domain] ||= begin
|
|
106
|
+
model = GenerativeModel.new(domain: domain)
|
|
107
|
+
@models[domain] = model if @models.size < MAX_MODELS
|
|
108
|
+
model
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def determine_mode(free_energy, precision)
|
|
113
|
+
if free_energy > FREE_ENERGY_THRESHOLD && precision >= ACTIVE_INFERENCE_THRESHOLD
|
|
114
|
+
:hybrid
|
|
115
|
+
elsif free_energy > ACTIVE_INFERENCE_THRESHOLD
|
|
116
|
+
:active
|
|
117
|
+
else
|
|
118
|
+
:perceptual
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module PredictiveProcessing
|
|
6
|
+
module Runners
|
|
7
|
+
module PredictiveProcessing
|
|
8
|
+
include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers) &&
|
|
9
|
+
Legion::Extensions::Helpers.const_defined?(:Lex)
|
|
10
|
+
|
|
11
|
+
def add_generative_model(domain:, **)
|
|
12
|
+
return { added: false, reason: :missing_domain } if domain.nil? || domain.to_s.strip.empty?
|
|
13
|
+
|
|
14
|
+
result = default_processor.add_model(domain: domain.to_sym)
|
|
15
|
+
Legion::Logging.debug "[predictive_processing] add_model domain=#{domain} added=#{result[:added]}"
|
|
16
|
+
result
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def predict_from_model(domain:, context: {}, **)
|
|
20
|
+
return { predicted: false, reason: :missing_domain } if domain.nil?
|
|
21
|
+
|
|
22
|
+
prediction = default_processor.predict(domain: domain.to_sym, context: context)
|
|
23
|
+
Legion::Logging.debug "[predictive_processing] predict domain=#{domain} " \
|
|
24
|
+
"expected=#{prediction[:expected_value]&.round(3)}"
|
|
25
|
+
{ predicted: true, domain: domain, prediction: prediction }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def observe_outcome(domain:, actual:, predicted:, **)
|
|
29
|
+
return { observed: false, reason: :missing_domain } if domain.nil?
|
|
30
|
+
|
|
31
|
+
result = default_processor.observe(
|
|
32
|
+
domain: domain.to_sym,
|
|
33
|
+
actual: actual,
|
|
34
|
+
predicted: predicted
|
|
35
|
+
)
|
|
36
|
+
log_observe(domain, result)
|
|
37
|
+
result
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def inference_mode(domain:, **)
|
|
41
|
+
return { mode: nil, reason: :missing_domain } if domain.nil?
|
|
42
|
+
|
|
43
|
+
mode = default_processor.inference_mode(domain.to_sym)
|
|
44
|
+
Legion::Logging.debug "[predictive_processing] inference_mode domain=#{domain} mode=#{mode}"
|
|
45
|
+
{ domain: domain, mode: mode }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def free_energy(domain: nil, **)
|
|
49
|
+
if domain
|
|
50
|
+
fe = default_processor.free_energy_for(domain.to_sym)
|
|
51
|
+
return { domain: domain, free_energy: nil, reason: :domain_not_found } if fe.nil?
|
|
52
|
+
|
|
53
|
+
{ domain: domain, free_energy: fe }
|
|
54
|
+
else
|
|
55
|
+
{ global_free_energy: default_processor.global_free_energy }
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def models_needing_update(**)
|
|
60
|
+
needing = default_processor.models_needing_update
|
|
61
|
+
Legion::Logging.debug "[predictive_processing] models_needing_update count=#{needing.size}"
|
|
62
|
+
{ count: needing.size, models: needing }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def active_inference_candidates(**)
|
|
66
|
+
candidates = default_processor.active_inference_candidates
|
|
67
|
+
Legion::Logging.debug "[predictive_processing] active_inference_candidates count=#{candidates.size}"
|
|
68
|
+
{ count: candidates.size, domains: candidates }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def update_predictive_processing(**)
|
|
72
|
+
default_processor.tick
|
|
73
|
+
Legion::Logging.debug '[predictive_processing] tick: precision decayed on all models'
|
|
74
|
+
{ ticked: true, model_count: default_processor.models.size }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def predictive_processing_stats(**)
|
|
78
|
+
stats = default_processor.to_h
|
|
79
|
+
Legion::Logging.debug "[predictive_processing] stats global_fe=#{stats[:global_free_energy]&.round(3)}"
|
|
80
|
+
{ success: true, stats: stats }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
private
|
|
84
|
+
|
|
85
|
+
def default_processor
|
|
86
|
+
@default_processor ||= Helpers::PredictiveProcessor.new
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def log_observe(domain, result)
|
|
90
|
+
return unless result[:observed]
|
|
91
|
+
|
|
92
|
+
Legion::Logging.debug "[predictive_processing] observe domain=#{domain} " \
|
|
93
|
+
"error=#{result[:prediction_error]&.round(3)} " \
|
|
94
|
+
"mode=#{result[:inference_mode]}"
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/predictive_processing/version'
|
|
4
|
+
require 'legion/extensions/predictive_processing/helpers/constants'
|
|
5
|
+
require 'legion/extensions/predictive_processing/helpers/generative_model'
|
|
6
|
+
require 'legion/extensions/predictive_processing/helpers/predictive_processor'
|
|
7
|
+
require 'legion/extensions/predictive_processing/runners/predictive_processing'
|
|
8
|
+
|
|
9
|
+
module Legion
|
|
10
|
+
module Extensions
|
|
11
|
+
module PredictiveProcessing
|
|
12
|
+
extend Legion::Extensions::Core if Legion::Extensions.const_defined? :Core
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe Legion::Extensions::PredictiveProcessing::Client do
|
|
4
|
+
subject(:client) { described_class.new }
|
|
5
|
+
|
|
6
|
+
describe '#initialize' do
|
|
7
|
+
it 'creates a client with a default processor' do
|
|
8
|
+
expect(client).to be_a(described_class)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
it 'accepts an injected processor' do
|
|
12
|
+
processor = Legion::Extensions::PredictiveProcessing::Helpers::PredictiveProcessor.new
|
|
13
|
+
injected = described_class.new(processor: processor)
|
|
14
|
+
result = injected.predictive_processing_stats
|
|
15
|
+
expect(result[:success]).to be true
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
describe 'full workflow: add -> predict -> observe -> stats' do
|
|
20
|
+
it 'completes a full predictive processing cycle' do
|
|
21
|
+
client.add_generative_model(domain: :workflow)
|
|
22
|
+
prediction = client.predict_from_model(domain: :workflow, context: { step: 1 })
|
|
23
|
+
expect(prediction[:predicted]).to be true
|
|
24
|
+
|
|
25
|
+
expected_val = prediction[:prediction][:expected_value]
|
|
26
|
+
obs = client.observe_outcome(domain: :workflow, actual: 0.8, predicted: expected_val)
|
|
27
|
+
expect(obs[:observed]).to be true
|
|
28
|
+
|
|
29
|
+
stats = client.predictive_processing_stats
|
|
30
|
+
expect(stats[:stats][:model_count]).to eq(1)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
it 'accumulates free energy after repeated high errors' do
|
|
34
|
+
client.add_generative_model(domain: :stress)
|
|
35
|
+
5.times { client.observe_outcome(domain: :stress, actual: 1.0, predicted: 0.0) }
|
|
36
|
+
fe = client.free_energy(domain: :stress)
|
|
37
|
+
expect(fe[:free_energy]).to be > 0.0
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
it 'identifies active inference candidate after surprise' do
|
|
41
|
+
client.add_generative_model(domain: :act_candidate)
|
|
42
|
+
5.times { client.observe_outcome(domain: :act_candidate, actual: 1.0, predicted: 0.0) }
|
|
43
|
+
result = client.active_inference_candidates
|
|
44
|
+
expect(result[:domains]).to include(:act_candidate)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
it 'tick decays precision over time' do
|
|
48
|
+
client.add_generative_model(domain: :decaying)
|
|
49
|
+
initial_weight = client.instance_variable_get(:@default_processor).precision_weight(:decaying)
|
|
50
|
+
client.update_predictive_processing
|
|
51
|
+
after_weight = client.instance_variable_get(:@default_processor).precision_weight(:decaying)
|
|
52
|
+
expect(after_weight).to be < initial_weight
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
describe 'constants' do
|
|
57
|
+
it 'exposes INFERENCE_MODES' do
|
|
58
|
+
expect(Legion::Extensions::PredictiveProcessing::Helpers::Constants::INFERENCE_MODES)
|
|
59
|
+
.to eq(%i[perceptual active hybrid])
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
it 'exposes MODEL_STATES' do
|
|
63
|
+
expect(Legion::Extensions::PredictiveProcessing::Helpers::Constants::MODEL_STATES)
|
|
64
|
+
.to eq(%i[stable updating exploring surprised])
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
it 'exposes FREE_ENERGY_THRESHOLD' do
|
|
68
|
+
expect(Legion::Extensions::PredictiveProcessing::Helpers::Constants::FREE_ENERGY_THRESHOLD)
|
|
69
|
+
.to eq(0.7)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
it 'exposes ACTIVE_INFERENCE_THRESHOLD' do
|
|
73
|
+
expect(Legion::Extensions::PredictiveProcessing::Helpers::Constants::ACTIVE_INFERENCE_THRESHOLD)
|
|
74
|
+
.to eq(0.5)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
it 'exposes all PRECISION_LABELS keys as ranges' do
|
|
78
|
+
labels = Legion::Extensions::PredictiveProcessing::Helpers::Constants::PRECISION_LABELS
|
|
79
|
+
expect(labels.values).to include(:certain, :confident, :uncertain, :vague, :noise)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe Legion::Extensions::PredictiveProcessing::Helpers::GenerativeModel do
|
|
4
|
+
subject(:model) { described_class.new(domain: :test_domain) }
|
|
5
|
+
|
|
6
|
+
describe '#initialize' do
|
|
7
|
+
it 'assigns a unique id' do
|
|
8
|
+
expect(model.id).to match(/\A[0-9a-f-]{36}\z/)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
it 'sets domain' do
|
|
12
|
+
expect(model.domain).to eq(:test_domain)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
it 'sets default confidence' do
|
|
16
|
+
expect(model.confidence).to eq(described_class::DEFAULT_PRECISION)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
it 'sets default precision' do
|
|
20
|
+
expect(model.precision).to eq(described_class::DEFAULT_PRECISION)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
it 'starts with zero prediction error' do
|
|
24
|
+
expect(model.prediction_error).to eq(0.0)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
it 'starts in stable state' do
|
|
28
|
+
expect(model.state).to eq(:stable)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
it 'records created_at timestamp' do
|
|
32
|
+
expect(model.created_at).to be_a(Time)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
it 'records updated_at timestamp' do
|
|
36
|
+
expect(model.updated_at).to be_a(Time)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
describe '#predict' do
|
|
41
|
+
it 'returns a prediction hash' do
|
|
42
|
+
result = model.predict(context: {})
|
|
43
|
+
expect(result).to be_a(Hash)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
it 'includes expected_value' do
|
|
47
|
+
result = model.predict(context: {})
|
|
48
|
+
expect(result[:expected_value]).to be_a(Float)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
it 'includes confidence' do
|
|
52
|
+
result = model.predict(context: {})
|
|
53
|
+
expect(result[:confidence]).to eq(model.confidence)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
it 'includes domain' do
|
|
57
|
+
result = model.predict(context: {})
|
|
58
|
+
expect(result[:domain]).to eq(:test_domain)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
it 'applies context richness bonus' do
|
|
62
|
+
sparse_pred = model.predict(context: {})
|
|
63
|
+
rich_pred = model.predict(context: { a: 1, b: 2, c: 3, d: 4, e: 5 })
|
|
64
|
+
expect(rich_pred[:expected_value]).to be >= sparse_pred[:expected_value]
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
it 'caps expected_value at 1.0' do
|
|
68
|
+
result = model.predict(context: (1..20).to_h { |i| [i, i] })
|
|
69
|
+
expect(result[:expected_value]).to be <= 1.0
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
it 'stores the prediction as last_prediction' do
|
|
73
|
+
model.predict(context: { key: :val })
|
|
74
|
+
expect(model.last_prediction).not_to be_nil
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
it 'uses default empty context when none provided' do
|
|
78
|
+
result = model.predict
|
|
79
|
+
expect(result[:context_size]).to eq(0)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
describe '#observe' do
|
|
84
|
+
it 'returns a numeric error magnitude' do
|
|
85
|
+
error = model.observe(actual: 0.8, predicted: 0.5)
|
|
86
|
+
expect(error).to be_a(Float)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
it 'computes absolute difference' do
|
|
90
|
+
error = model.observe(actual: 0.8, predicted: 0.5)
|
|
91
|
+
expect(error).to be_within(0.01).of(0.3)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
it 'clamps error to 0..1' do
|
|
95
|
+
error = model.observe(actual: 5.0, predicted: 0.0)
|
|
96
|
+
expect(error).to eq(1.0)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
it 'returns 0.0 for non-numeric inputs' do
|
|
100
|
+
error = model.observe(actual: 'abc', predicted: 0.5)
|
|
101
|
+
expect(error).to eq(0.0)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
it 'updates updated_at' do
|
|
105
|
+
before = model.updated_at
|
|
106
|
+
sleep(0.01)
|
|
107
|
+
model.observe(actual: 0.9, predicted: 0.1)
|
|
108
|
+
expect(model.updated_at).to be >= before
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
describe '#free_energy' do
|
|
113
|
+
it 'returns 0.0 with no history' do
|
|
114
|
+
expect(model.free_energy).to eq(0.0)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
it 'returns a positive value after observations' do
|
|
118
|
+
model.observe(actual: 1.0, predicted: 0.0)
|
|
119
|
+
expect(model.free_energy).to be >= 0.0
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
it 'returns higher free energy for larger errors' do
|
|
123
|
+
low_model = described_class.new(domain: :low)
|
|
124
|
+
high_model = described_class.new(domain: :high)
|
|
125
|
+
low_model.observe(actual: 0.51, predicted: 0.5)
|
|
126
|
+
high_model.observe(actual: 1.0, predicted: 0.0)
|
|
127
|
+
expect(high_model.free_energy).to be > low_model.free_energy
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
describe '#stable?' do
|
|
132
|
+
it 'returns true when free_energy is low' do
|
|
133
|
+
expect(model.stable?).to be true
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
it 'returns false when surprised' do
|
|
137
|
+
5.times { model.observe(actual: 1.0, predicted: 0.0) }
|
|
138
|
+
expect(model.stable?).to be false
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
describe '#surprised?' do
|
|
143
|
+
it 'returns false initially' do
|
|
144
|
+
expect(model.surprised?).to be false
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
it 'returns true after repeated large errors' do
|
|
148
|
+
5.times { model.observe(actual: 1.0, predicted: 0.0) }
|
|
149
|
+
expect(model.surprised?).to be true
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
describe '#update_model' do
|
|
154
|
+
it 'adjusts confidence downward on positive error' do
|
|
155
|
+
before = model.confidence
|
|
156
|
+
model.update_model(error: 0.5)
|
|
157
|
+
expect(model.confidence).to be < before
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
it 'does not drop confidence below MODEL_CONFIDENCE_FLOOR' do
|
|
161
|
+
10.times { model.update_model(error: 1.0) }
|
|
162
|
+
expect(model.confidence).to be >= described_class::MODEL_CONFIDENCE_FLOOR
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
it 'sets state to updating' do
|
|
166
|
+
model.update_model(error: 0.3)
|
|
167
|
+
expect(model.state).to eq(:updating)
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
describe '#decay' do
|
|
172
|
+
it 'reduces precision' do
|
|
173
|
+
before = model.precision
|
|
174
|
+
model.decay
|
|
175
|
+
expect(model.precision).to be < before
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
it 'does not drop precision below PRECISION_FLOOR' do
|
|
179
|
+
100.times { model.decay }
|
|
180
|
+
expect(model.precision).to be >= described_class::PRECISION_FLOOR
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
describe '#precision_label' do
|
|
185
|
+
it 'returns :uncertain for default precision (0.5)' do
|
|
186
|
+
expect(model.precision_label).to eq(:uncertain)
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
it 'returns :certain for high precision' do
|
|
190
|
+
allow(model).to receive(:precision).and_return(0.9)
|
|
191
|
+
expect(model.precision_label).to eq(:certain)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
it 'returns :noise for very low precision' do
|
|
195
|
+
allow(model).to receive(:precision).and_return(0.1)
|
|
196
|
+
expect(model.precision_label).to eq(:noise)
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
it 'returns :vague for precision around 0.3' do
|
|
200
|
+
allow(model).to receive(:precision).and_return(0.3)
|
|
201
|
+
expect(model.precision_label).to eq(:vague)
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
describe '#to_h' do
|
|
206
|
+
it 'returns a hash with all required keys' do
|
|
207
|
+
h = model.to_h
|
|
208
|
+
%i[id domain confidence precision prediction_error free_energy state
|
|
209
|
+
precision_label stable surprised history_size created_at updated_at].each do |key|
|
|
210
|
+
expect(h).to have_key(key)
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
it 'reflects current model state' do
|
|
215
|
+
model.observe(actual: 0.8, predicted: 0.3)
|
|
216
|
+
h = model.to_h
|
|
217
|
+
expect(h[:history_size]).to eq(1)
|
|
218
|
+
end
|
|
219
|
+
end
|
|
220
|
+
end
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe Legion::Extensions::PredictiveProcessing::Helpers::PredictiveProcessor do
|
|
4
|
+
subject(:processor) { described_class.new }
|
|
5
|
+
|
|
6
|
+
describe '#add_model' do
|
|
7
|
+
it 'adds a new model for a domain' do
|
|
8
|
+
result = processor.add_model(domain: :perception)
|
|
9
|
+
expect(result[:added]).to be true
|
|
10
|
+
expect(result[:domain]).to eq(:perception)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
it 'returns model_id on success' do
|
|
14
|
+
result = processor.add_model(domain: :cognition)
|
|
15
|
+
expect(result[:model_id]).to match(/\A[0-9a-f-]{36}\z/)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
it 'rejects duplicate domains' do
|
|
19
|
+
processor.add_model(domain: :dup)
|
|
20
|
+
result = processor.add_model(domain: :dup)
|
|
21
|
+
expect(result[:added]).to be false
|
|
22
|
+
expect(result[:reason]).to eq(:already_exists)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
it 'rejects when at model limit' do
|
|
26
|
+
20.times { |i| processor.add_model(domain: :"domain_#{i}") }
|
|
27
|
+
result = processor.add_model(domain: :overflow)
|
|
28
|
+
expect(result[:added]).to be false
|
|
29
|
+
expect(result[:reason]).to eq(:limit_reached)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
describe '#predict' do
|
|
34
|
+
it 'returns a prediction hash for a known domain' do
|
|
35
|
+
processor.add_model(domain: :action)
|
|
36
|
+
result = processor.predict(domain: :action, context: { urgency: 0.8 })
|
|
37
|
+
expect(result[:expected_value]).to be_a(Float)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
it 'auto-creates model for unknown domain (within limit)' do
|
|
41
|
+
result = processor.predict(domain: :new_domain, context: {})
|
|
42
|
+
expect(result[:expected_value]).to be_a(Float)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
it 'includes domain in prediction' do
|
|
46
|
+
result = processor.predict(domain: :emotion, context: {})
|
|
47
|
+
expect(result[:domain]).to eq(:emotion)
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
describe '#observe' do
|
|
52
|
+
before { processor.add_model(domain: :vision) }
|
|
53
|
+
|
|
54
|
+
it 'returns observed: true for known domain' do
|
|
55
|
+
result = processor.observe(domain: :vision, actual: 0.7, predicted: 0.5)
|
|
56
|
+
expect(result[:observed]).to be true
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
it 'returns prediction_error' do
|
|
60
|
+
result = processor.observe(domain: :vision, actual: 0.8, predicted: 0.5)
|
|
61
|
+
expect(result[:prediction_error]).to be_a(Float)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
it 'returns inference_mode' do
|
|
65
|
+
result = processor.observe(domain: :vision, actual: 0.8, predicted: 0.5)
|
|
66
|
+
expect(described_class::INFERENCE_MODES).to include(result[:inference_mode])
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
it 'returns observed: false for unknown domain' do
|
|
70
|
+
result = processor.observe(domain: :unknown, actual: 0.5, predicted: 0.5)
|
|
71
|
+
expect(result[:observed]).to be false
|
|
72
|
+
expect(result[:reason]).to eq(:domain_not_found)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
it 'returns free_energy after observation' do
|
|
76
|
+
result = processor.observe(domain: :vision, actual: 1.0, predicted: 0.0)
|
|
77
|
+
expect(result[:free_energy]).to be_a(Float)
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
describe '#inference_mode' do
|
|
82
|
+
it 'returns :perceptual for unknown domain' do
|
|
83
|
+
expect(processor.inference_mode(:nonexistent)).to eq(:perceptual)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
it 'returns a valid inference mode for known domain' do
|
|
87
|
+
processor.add_model(domain: :memory)
|
|
88
|
+
mode = processor.inference_mode(:memory)
|
|
89
|
+
expect(described_class::INFERENCE_MODES).to include(mode)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
it 'returns :active or :hybrid when free energy is high' do
|
|
93
|
+
processor.add_model(domain: :surprise)
|
|
94
|
+
5.times { processor.observe(domain: :surprise, actual: 1.0, predicted: 0.0) }
|
|
95
|
+
mode = processor.inference_mode(:surprise)
|
|
96
|
+
expect(%i[active hybrid]).to include(mode)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
describe '#free_energy_for' do
|
|
101
|
+
it 'returns nil for unknown domain' do
|
|
102
|
+
expect(processor.free_energy_for(:unknown)).to be_nil
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
it 'returns a float for known domain' do
|
|
106
|
+
processor.add_model(domain: :planning)
|
|
107
|
+
expect(processor.free_energy_for(:planning)).to be_a(Float)
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
describe '#global_free_energy' do
|
|
112
|
+
it 'returns 0.0 with no models' do
|
|
113
|
+
expect(processor.global_free_energy).to eq(0.0)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
it 'returns average free energy across models' do
|
|
117
|
+
processor.add_model(domain: :d1)
|
|
118
|
+
processor.add_model(domain: :d2)
|
|
119
|
+
expect(processor.global_free_energy).to be_a(Float)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
describe '#precision_weight' do
|
|
124
|
+
it 'returns DEFAULT_PRECISION for unknown domain' do
|
|
125
|
+
expect(processor.precision_weight(:unknown)).to eq(described_class::DEFAULT_PRECISION)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
it 'returns model precision for known domain' do
|
|
129
|
+
processor.add_model(domain: :known)
|
|
130
|
+
expect(processor.precision_weight(:known)).to be_a(Float)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
describe '#models_needing_update' do
|
|
135
|
+
it 'returns empty hash when all models are stable' do
|
|
136
|
+
expect(processor.models_needing_update).to be_empty
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
it 'returns domains with high free energy' do
|
|
140
|
+
processor.add_model(domain: :erratic)
|
|
141
|
+
5.times { processor.observe(domain: :erratic, actual: 1.0, predicted: 0.0) }
|
|
142
|
+
needing = processor.models_needing_update
|
|
143
|
+
expect(needing).to have_key(:erratic)
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
describe '#stable_models' do
|
|
148
|
+
it 'returns all models initially (all stable)' do
|
|
149
|
+
processor.add_model(domain: :calm)
|
|
150
|
+
expect(processor.stable_models).to have_key(:calm)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
it 'excludes surprised models' do
|
|
154
|
+
processor.add_model(domain: :upset)
|
|
155
|
+
5.times { processor.observe(domain: :upset, actual: 1.0, predicted: 0.0) }
|
|
156
|
+
expect(processor.stable_models).not_to have_key(:upset)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
describe '#active_inference_candidates' do
|
|
161
|
+
it 'returns empty array when all models have low free energy' do
|
|
162
|
+
expect(processor.active_inference_candidates).to be_empty
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
it 'returns domains exceeding active inference threshold' do
|
|
166
|
+
processor.add_model(domain: :high_fe)
|
|
167
|
+
5.times { processor.observe(domain: :high_fe, actual: 1.0, predicted: 0.0) }
|
|
168
|
+
expect(processor.active_inference_candidates).to include(:high_fe)
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
describe '#tick' do
|
|
173
|
+
it 'decays precision on all models' do
|
|
174
|
+
processor.add_model(domain: :tickable)
|
|
175
|
+
before = processor.models[:tickable].precision
|
|
176
|
+
processor.tick
|
|
177
|
+
expect(processor.models[:tickable].precision).to be < before
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
it 'does not raise with no models' do
|
|
181
|
+
expect { processor.tick }.not_to raise_error
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
describe '#to_h' do
|
|
186
|
+
it 'includes model_count' do
|
|
187
|
+
processor.add_model(domain: :counted)
|
|
188
|
+
expect(processor.to_h[:model_count]).to eq(1)
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
it 'includes global_free_energy' do
|
|
192
|
+
expect(processor.to_h[:global_free_energy]).to be_a(Float)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
it 'includes models hash' do
|
|
196
|
+
processor.add_model(domain: :listed)
|
|
197
|
+
expect(processor.to_h[:models]).to have_key(:listed)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
it 'includes counts for needing update and stable' do
|
|
201
|
+
h = processor.to_h
|
|
202
|
+
expect(h).to have_key(:models_needing_update)
|
|
203
|
+
expect(h).to have_key(:stable_model_count)
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe Legion::Extensions::PredictiveProcessing::Runners::PredictiveProcessing do
|
|
4
|
+
let(:client) { Legion::Extensions::PredictiveProcessing::Client.new }
|
|
5
|
+
|
|
6
|
+
describe '#add_generative_model' do
|
|
7
|
+
it 'adds a model for a valid domain' do
|
|
8
|
+
result = client.add_generative_model(domain: :perception)
|
|
9
|
+
expect(result[:added]).to be true
|
|
10
|
+
expect(result[:domain]).to eq(:perception)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
it 'returns error for nil domain' do
|
|
14
|
+
result = client.add_generative_model(domain: nil)
|
|
15
|
+
expect(result[:added]).to be false
|
|
16
|
+
expect(result[:reason]).to eq(:missing_domain)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
it 'returns error for empty string domain' do
|
|
20
|
+
result = client.add_generative_model(domain: '')
|
|
21
|
+
expect(result[:added]).to be false
|
|
22
|
+
expect(result[:reason]).to eq(:missing_domain)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
it 'returns model_id on success' do
|
|
26
|
+
result = client.add_generative_model(domain: :with_id)
|
|
27
|
+
expect(result[:model_id]).to match(/\A[0-9a-f-]{36}\z/)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
it 'rejects duplicate domain' do
|
|
31
|
+
client.add_generative_model(domain: :dup)
|
|
32
|
+
result = client.add_generative_model(domain: :dup)
|
|
33
|
+
expect(result[:added]).to be false
|
|
34
|
+
expect(result[:reason]).to eq(:already_exists)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
describe '#predict_from_model' do
|
|
39
|
+
before { client.add_generative_model(domain: :action) }
|
|
40
|
+
|
|
41
|
+
it 'returns a prediction for known domain' do
|
|
42
|
+
result = client.predict_from_model(domain: :action, context: {})
|
|
43
|
+
expect(result[:predicted]).to be true
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
it 'includes domain in result' do
|
|
47
|
+
result = client.predict_from_model(domain: :action)
|
|
48
|
+
expect(result[:domain]).to eq(:action)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
it 'includes prediction hash' do
|
|
52
|
+
result = client.predict_from_model(domain: :action, context: { val: 1 })
|
|
53
|
+
expect(result[:prediction]).to be_a(Hash)
|
|
54
|
+
expect(result[:prediction][:expected_value]).to be_a(Float)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
it 'returns error for nil domain' do
|
|
58
|
+
result = client.predict_from_model(domain: nil)
|
|
59
|
+
expect(result[:predicted]).to be false
|
|
60
|
+
expect(result[:reason]).to eq(:missing_domain)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
it 'auto-creates model for unknown domain' do
|
|
64
|
+
result = client.predict_from_model(domain: :auto_created, context: {})
|
|
65
|
+
expect(result[:predicted]).to be true
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
describe '#observe_outcome' do
|
|
70
|
+
before { client.add_generative_model(domain: :vision) }
|
|
71
|
+
|
|
72
|
+
it 'returns observed: true for known domain' do
|
|
73
|
+
result = client.observe_outcome(domain: :vision, actual: 0.7, predicted: 0.5)
|
|
74
|
+
expect(result[:observed]).to be true
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
it 'includes inference_mode' do
|
|
78
|
+
result = client.observe_outcome(domain: :vision, actual: 0.6, predicted: 0.5)
|
|
79
|
+
expect(Legion::Extensions::PredictiveProcessing::Helpers::Constants::INFERENCE_MODES)
|
|
80
|
+
.to include(result[:inference_mode])
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
it 'returns error for nil domain' do
|
|
84
|
+
result = client.observe_outcome(domain: nil, actual: 0.5, predicted: 0.5)
|
|
85
|
+
expect(result[:observed]).to be false
|
|
86
|
+
expect(result[:reason]).to eq(:missing_domain)
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
it 'returns false for unknown domain' do
|
|
90
|
+
result = client.observe_outcome(domain: :unknown_x, actual: 0.5, predicted: 0.5)
|
|
91
|
+
expect(result[:observed]).to be false
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
it 'includes free_energy in result' do
|
|
95
|
+
result = client.observe_outcome(domain: :vision, actual: 0.9, predicted: 0.1)
|
|
96
|
+
expect(result[:free_energy]).to be_a(Float)
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
describe '#inference_mode' do
|
|
101
|
+
it 'returns mode for known domain' do
|
|
102
|
+
client.add_generative_model(domain: :motor)
|
|
103
|
+
result = client.inference_mode(domain: :motor)
|
|
104
|
+
expect(result[:domain]).to eq(:motor)
|
|
105
|
+
expect(Legion::Extensions::PredictiveProcessing::Helpers::Constants::INFERENCE_MODES)
|
|
106
|
+
.to include(result[:mode])
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
it 'returns error for nil domain' do
|
|
110
|
+
result = client.inference_mode(domain: nil)
|
|
111
|
+
expect(result[:mode]).to be_nil
|
|
112
|
+
expect(result[:reason]).to eq(:missing_domain)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
it 'returns :perceptual for unknown domain' do
|
|
116
|
+
result = client.inference_mode(domain: :unregistered)
|
|
117
|
+
expect(result[:mode]).to eq(:perceptual)
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
describe '#free_energy' do
|
|
122
|
+
it 'returns global free energy when no domain given' do
|
|
123
|
+
result = client.free_energy
|
|
124
|
+
expect(result).to have_key(:global_free_energy)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
it 'returns domain free energy when domain given' do
|
|
128
|
+
client.add_generative_model(domain: :fe_domain)
|
|
129
|
+
result = client.free_energy(domain: :fe_domain)
|
|
130
|
+
expect(result[:domain]).to eq(:fe_domain)
|
|
131
|
+
expect(result[:free_energy]).to be_a(Float)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
it 'returns not_found for unknown domain' do
|
|
135
|
+
result = client.free_energy(domain: :no_such)
|
|
136
|
+
expect(result[:reason]).to eq(:domain_not_found)
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
describe '#models_needing_update' do
|
|
141
|
+
it 'returns count and models hash' do
|
|
142
|
+
result = client.models_needing_update
|
|
143
|
+
expect(result).to have_key(:count)
|
|
144
|
+
expect(result).to have_key(:models)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
it 'returns 0 when no models exist' do
|
|
148
|
+
expect(client.models_needing_update[:count]).to eq(0)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
it 'returns surprised models' do
|
|
152
|
+
client.add_generative_model(domain: :volatile)
|
|
153
|
+
5.times { client.observe_outcome(domain: :volatile, actual: 1.0, predicted: 0.0) }
|
|
154
|
+
result = client.models_needing_update
|
|
155
|
+
expect(result[:count]).to be >= 1
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
describe '#active_inference_candidates' do
|
|
160
|
+
it 'returns count and domains array' do
|
|
161
|
+
result = client.active_inference_candidates
|
|
162
|
+
expect(result).to have_key(:count)
|
|
163
|
+
expect(result).to have_key(:domains)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
it 'returns 0 when no high-free-energy models' do
|
|
167
|
+
expect(client.active_inference_candidates[:count]).to eq(0)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
it 'includes domains with high free energy' do
|
|
171
|
+
client.add_generative_model(domain: :erratic)
|
|
172
|
+
5.times { client.observe_outcome(domain: :erratic, actual: 1.0, predicted: 0.0) }
|
|
173
|
+
result = client.active_inference_candidates
|
|
174
|
+
expect(result[:domains]).to include(:erratic)
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
describe '#update_predictive_processing' do
|
|
179
|
+
it 'ticks the processor' do
|
|
180
|
+
result = client.update_predictive_processing
|
|
181
|
+
expect(result[:ticked]).to be true
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
it 'reports model count' do
|
|
185
|
+
client.add_generative_model(domain: :tracked)
|
|
186
|
+
result = client.update_predictive_processing
|
|
187
|
+
expect(result[:model_count]).to eq(1)
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
describe '#predictive_processing_stats' do
|
|
192
|
+
it 'returns success: true' do
|
|
193
|
+
result = client.predictive_processing_stats
|
|
194
|
+
expect(result[:success]).to be true
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
it 'returns stats hash' do
|
|
198
|
+
result = client.predictive_processing_stats
|
|
199
|
+
expect(result[:stats]).to be_a(Hash)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
it 'includes global_free_energy in stats' do
|
|
203
|
+
result = client.predictive_processing_stats
|
|
204
|
+
expect(result[:stats]).to have_key(:global_free_energy)
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
it 'reflects added models in stats' do
|
|
208
|
+
client.add_generative_model(domain: :counted)
|
|
209
|
+
result = client.predictive_processing_stats
|
|
210
|
+
expect(result[:stats][:model_count]).to eq(1)
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
end
|
data/spec/spec_helper.rb
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/predictive_processing/helpers/constants'
|
|
4
|
+
require 'legion/extensions/predictive_processing/helpers/generative_model'
|
|
5
|
+
require 'legion/extensions/predictive_processing/helpers/predictive_processor'
|
|
6
|
+
require 'legion/extensions/predictive_processing/runners/predictive_processing'
|
|
7
|
+
require 'legion/extensions/predictive_processing/client'
|
|
8
|
+
|
|
9
|
+
module Legion
|
|
10
|
+
module Extensions
|
|
11
|
+
module Helpers
|
|
12
|
+
module Lex; end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
module Legion
|
|
18
|
+
module Logging
|
|
19
|
+
def self.method_missing(*); end
|
|
20
|
+
|
|
21
|
+
def self.respond_to_missing?(*) = true
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
RSpec.configure do |config|
|
|
26
|
+
config.example_status_persistence_file_path = '.rspec_status'
|
|
27
|
+
config.disable_monkey_patching!
|
|
28
|
+
config.expect_with(:rspec) { |c| c.syntax = :expect }
|
|
29
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: lex-predictive-processing
|
|
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: 'Andy Clark''s Predictive Processing / Active Inference for brain-modeled
|
|
27
|
+
agentic AI: generative models, precision-weighting, active inference, and free energy
|
|
28
|
+
minimization'
|
|
29
|
+
email:
|
|
30
|
+
- matthewdiverson@gmail.com
|
|
31
|
+
executables: []
|
|
32
|
+
extensions: []
|
|
33
|
+
extra_rdoc_files: []
|
|
34
|
+
files:
|
|
35
|
+
- Gemfile
|
|
36
|
+
- lex-predictive-processing.gemspec
|
|
37
|
+
- lib/legion/extensions/predictive_processing.rb
|
|
38
|
+
- lib/legion/extensions/predictive_processing/client.rb
|
|
39
|
+
- lib/legion/extensions/predictive_processing/helpers/constants.rb
|
|
40
|
+
- lib/legion/extensions/predictive_processing/helpers/generative_model.rb
|
|
41
|
+
- lib/legion/extensions/predictive_processing/helpers/predictive_processor.rb
|
|
42
|
+
- lib/legion/extensions/predictive_processing/runners/predictive_processing.rb
|
|
43
|
+
- lib/legion/extensions/predictive_processing/version.rb
|
|
44
|
+
- spec/legion/extensions/predictive_processing/client_spec.rb
|
|
45
|
+
- spec/legion/extensions/predictive_processing/helpers/generative_model_spec.rb
|
|
46
|
+
- spec/legion/extensions/predictive_processing/helpers/predictive_processor_spec.rb
|
|
47
|
+
- spec/legion/extensions/predictive_processing/runners/predictive_processing_spec.rb
|
|
48
|
+
- spec/spec_helper.rb
|
|
49
|
+
homepage: https://github.com/LegionIO/lex-predictive-processing
|
|
50
|
+
licenses:
|
|
51
|
+
- MIT
|
|
52
|
+
metadata:
|
|
53
|
+
homepage_uri: https://github.com/LegionIO/lex-predictive-processing
|
|
54
|
+
source_code_uri: https://github.com/LegionIO/lex-predictive-processing
|
|
55
|
+
documentation_uri: https://github.com/LegionIO/lex-predictive-processing
|
|
56
|
+
changelog_uri: https://github.com/LegionIO/lex-predictive-processing
|
|
57
|
+
bug_tracker_uri: https://github.com/LegionIO/lex-predictive-processing/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 Predictive Processing
|
|
76
|
+
test_files: []
|