lex-predictive-coding 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-coding.gemspec +29 -0
- data/lib/legion/extensions/predictive_coding/actors/decay.rb +41 -0
- data/lib/legion/extensions/predictive_coding/client.rb +24 -0
- data/lib/legion/extensions/predictive_coding/helpers/constants.rb +42 -0
- data/lib/legion/extensions/predictive_coding/helpers/generative_model.rb +183 -0
- data/lib/legion/extensions/predictive_coding/helpers/prediction_error.rb +55 -0
- data/lib/legion/extensions/predictive_coding/runners/predictive_coding.rb +167 -0
- data/lib/legion/extensions/predictive_coding/version.rb +9 -0
- data/lib/legion/extensions/predictive_coding.rb +16 -0
- data/spec/legion/extensions/predictive_coding/client_spec.rb +74 -0
- data/spec/legion/extensions/predictive_coding/helpers/generative_model_spec.rb +194 -0
- data/spec/legion/extensions/predictive_coding/helpers/prediction_error_spec.rb +109 -0
- data/spec/legion/extensions/predictive_coding/runners/predictive_coding_spec.rb +210 -0
- data/spec/spec_helper.rb +20 -0
- metadata +76 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: be107566fcfb814bdea9f4857e1023b4cbf47ca1d2959126f28e518bf909b7f3
|
|
4
|
+
data.tar.gz: 9ce453e5312273b215249a6b2bf77456dfec0e2ec252e48bf60bf0e7377d34db
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 2f79dd0c0450927598f12c3f16e15001feeadf8f5582817420634579259d40d6be233491dff9d3546a3435f1ad5d0236b55ec44bff64ac313c3d936031cac84a
|
|
7
|
+
data.tar.gz: d5c4493f99c551d3ce1bcd9062f8cc78e62bf775ef9ad61581be8acc405d6d8845a9b3cf2e751afedd6cbca0292a627800661dfea9b68854346bb3cd4e2fb1b4
|
data/Gemfile
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'lib/legion/extensions/predictive_coding/version'
|
|
4
|
+
|
|
5
|
+
Gem::Specification.new do |spec|
|
|
6
|
+
spec.name = 'lex-predictive-coding'
|
|
7
|
+
spec.version = Legion::Extensions::PredictiveCoding::VERSION
|
|
8
|
+
spec.authors = ['Esity']
|
|
9
|
+
spec.email = ['matthewdiverson@gmail.com']
|
|
10
|
+
|
|
11
|
+
spec.summary = 'LEX Predictive Coding'
|
|
12
|
+
spec.description = "Karl Friston's Free Energy Principle / Predictive Processing framework for brain-modeled agentic AI"
|
|
13
|
+
spec.homepage = 'https://github.com/LegionIO/lex-predictive-coding'
|
|
14
|
+
spec.license = 'MIT'
|
|
15
|
+
spec.required_ruby_version = '>= 3.4'
|
|
16
|
+
|
|
17
|
+
spec.metadata['homepage_uri'] = spec.homepage
|
|
18
|
+
spec.metadata['source_code_uri'] = 'https://github.com/LegionIO/lex-predictive-coding'
|
|
19
|
+
spec.metadata['documentation_uri'] = 'https://github.com/LegionIO/lex-predictive-coding'
|
|
20
|
+
spec.metadata['changelog_uri'] = 'https://github.com/LegionIO/lex-predictive-coding'
|
|
21
|
+
spec.metadata['bug_tracker_uri'] = 'https://github.com/LegionIO/lex-predictive-coding/issues'
|
|
22
|
+
spec.metadata['rubygems_mfa_required'] = 'true'
|
|
23
|
+
|
|
24
|
+
spec.files = Dir.chdir(File.expand_path(__dir__)) do
|
|
25
|
+
Dir.glob('{lib,spec}/**/*') + %w[lex-predictive-coding.gemspec Gemfile]
|
|
26
|
+
end
|
|
27
|
+
spec.require_paths = ['lib']
|
|
28
|
+
spec.add_development_dependency 'legion-gaia'
|
|
29
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/actors/every'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module Extensions
|
|
7
|
+
module PredictiveCoding
|
|
8
|
+
module Actor
|
|
9
|
+
class Decay < Legion::Extensions::Actors::Every
|
|
10
|
+
def runner_class
|
|
11
|
+
Legion::Extensions::PredictiveCoding::Runners::PredictiveCoding
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def runner_function
|
|
15
|
+
'update_predictive_coding'
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def time
|
|
19
|
+
60
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def run_now?
|
|
23
|
+
false
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def use_runner?
|
|
27
|
+
false
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def check_subtask?
|
|
31
|
+
false
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def generate_task?
|
|
35
|
+
false
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/predictive_coding/helpers/constants'
|
|
4
|
+
require 'legion/extensions/predictive_coding/helpers/prediction_error'
|
|
5
|
+
require 'legion/extensions/predictive_coding/helpers/generative_model'
|
|
6
|
+
require 'legion/extensions/predictive_coding/runners/predictive_coding'
|
|
7
|
+
|
|
8
|
+
module Legion
|
|
9
|
+
module Extensions
|
|
10
|
+
module PredictiveCoding
|
|
11
|
+
class Client
|
|
12
|
+
include Runners::PredictiveCoding
|
|
13
|
+
|
|
14
|
+
def initialize(generative_model: nil, **)
|
|
15
|
+
@generative_model = generative_model || Helpers::GenerativeModel.new
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
attr_reader :generative_model
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module PredictiveCoding
|
|
6
|
+
module Helpers
|
|
7
|
+
module Constants
|
|
8
|
+
MAX_PREDICTIONS = 200
|
|
9
|
+
MAX_ERROR_HISTORY = 500
|
|
10
|
+
MAX_MODELS = 20
|
|
11
|
+
DEFAULT_PRECISION = 0.5
|
|
12
|
+
PRECISION_FLOOR = 0.05
|
|
13
|
+
PRECISION_ALPHA = 0.12 # EMA for precision updates
|
|
14
|
+
ERROR_ALPHA = 0.15 # EMA for prediction error smoothing
|
|
15
|
+
MODEL_LEARNING_RATE = 0.1
|
|
16
|
+
FREE_ENERGY_ALPHA = 0.1 # EMA for free energy tracking
|
|
17
|
+
COMPLEXITY_PENALTY = 0.05 # penalizes overly complex models
|
|
18
|
+
PREDICTION_DECAY = 0.01
|
|
19
|
+
PRECISION_DECAY = 0.005
|
|
20
|
+
MAX_ACTIVE_INFERENCES = 50
|
|
21
|
+
SURPRISE_THRESHOLD = 0.7 # above this, prediction error is "surprising"
|
|
22
|
+
|
|
23
|
+
PREDICTION_ERROR_LEVELS = {
|
|
24
|
+
negligible: 0.0..0.1,
|
|
25
|
+
low: 0.1..0.3,
|
|
26
|
+
moderate: 0.3..0.5,
|
|
27
|
+
high: 0.5..0.7,
|
|
28
|
+
surprising: 0.7..1.0
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
31
|
+
FREE_ENERGY_LEVELS = {
|
|
32
|
+
minimal: 0.0..0.2,
|
|
33
|
+
low: 0.2..0.4,
|
|
34
|
+
moderate: 0.4..0.6,
|
|
35
|
+
elevated: 0.6..0.8,
|
|
36
|
+
critical: 0.8..Float::INFINITY
|
|
37
|
+
}.freeze
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module PredictiveCoding
|
|
6
|
+
module Helpers
|
|
7
|
+
class GenerativeModel
|
|
8
|
+
attr_reader :model_id, :created_at
|
|
9
|
+
|
|
10
|
+
def initialize(model_id: nil)
|
|
11
|
+
@model_id = model_id || SecureRandom.uuid
|
|
12
|
+
@created_at = Time.now.utc
|
|
13
|
+
@predictions = {} # domain -> { value, confidence, updated_at }
|
|
14
|
+
@error_history = [] # array of PredictionError objects (capped)
|
|
15
|
+
@precisions = {} # domain -> float (0..1)
|
|
16
|
+
@free_energy_ema = 0.0
|
|
17
|
+
@domain_models = {} # domain -> simple weighted mean tracker
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def predict(domain:, context: {})
|
|
21
|
+
prior = @domain_models[domain]
|
|
22
|
+
if prior
|
|
23
|
+
confidence = @precisions.fetch(domain, Constants::DEFAULT_PRECISION)
|
|
24
|
+
value = prior[:mean]
|
|
25
|
+
else
|
|
26
|
+
confidence = Constants::DEFAULT_PRECISION
|
|
27
|
+
value = context[:expected] || context[:baseline] || 0.5
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
@predictions[domain] = { value: value, confidence: confidence, updated_at: Time.now.utc }
|
|
31
|
+
|
|
32
|
+
{ domain: domain, predicted: value, confidence: confidence }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def update(domain:, predicted:, actual:)
|
|
36
|
+
precision = @precisions.fetch(domain, Constants::DEFAULT_PRECISION)
|
|
37
|
+
error = PredictionError.new(domain: domain, predicted: predicted, actual: actual, precision: precision)
|
|
38
|
+
|
|
39
|
+
record_error(error)
|
|
40
|
+
update_precision(domain, error.error_magnitude)
|
|
41
|
+
update_domain_model(domain, actual)
|
|
42
|
+
update_free_energy(error.error_magnitude)
|
|
43
|
+
|
|
44
|
+
error
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def precision_for(domain:)
|
|
48
|
+
@precisions.fetch(domain, Constants::DEFAULT_PRECISION)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def free_energy
|
|
52
|
+
prediction_error_term = average_weighted_error
|
|
53
|
+
complexity_term = @domain_models.size * Constants::COMPLEXITY_PENALTY
|
|
54
|
+
@free_energy_ema = ema(@free_energy_ema, prediction_error_term + complexity_term, Constants::FREE_ENERGY_ALPHA)
|
|
55
|
+
@free_energy_ema
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def free_energy_level
|
|
59
|
+
fe = free_energy
|
|
60
|
+
Constants::FREE_ENERGY_LEVELS.find { |_k, range| range.cover?(fe) }&.first || :unknown
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def active_inference_candidates
|
|
64
|
+
@domain_models.keys.select do |domain|
|
|
65
|
+
precision = @precisions.fetch(domain, Constants::DEFAULT_PRECISION)
|
|
66
|
+
recent_errors = recent_errors_for(domain)
|
|
67
|
+
next false if recent_errors.empty?
|
|
68
|
+
|
|
69
|
+
avg_error = recent_errors.sum(&:error_magnitude) / recent_errors.size
|
|
70
|
+
avg_error > Constants::SURPRISE_THRESHOLD && precision < 0.6
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def surprising_errors
|
|
75
|
+
@error_history.select(&:surprising?)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def all_errors
|
|
79
|
+
@error_history
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def decay_all
|
|
83
|
+
@precisions.each_key do |domain|
|
|
84
|
+
current = @precisions[domain]
|
|
85
|
+
decayed = [current - Constants::PRECISION_DECAY, Constants::PRECISION_FLOOR].max
|
|
86
|
+
@precisions[domain] = decayed
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
prune_old_errors
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def domain_count
|
|
93
|
+
@domain_models.size
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def error_count
|
|
97
|
+
@error_history.size
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def to_h
|
|
101
|
+
{
|
|
102
|
+
model_id: @model_id,
|
|
103
|
+
created_at: @created_at,
|
|
104
|
+
domain_count: @domain_models.size,
|
|
105
|
+
error_count: @error_history.size,
|
|
106
|
+
free_energy: free_energy.round(4),
|
|
107
|
+
free_energy_level: free_energy_level,
|
|
108
|
+
surprising_count: surprising_errors.size,
|
|
109
|
+
domains: domain_stats
|
|
110
|
+
}
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
private
|
|
114
|
+
|
|
115
|
+
def record_error(error)
|
|
116
|
+
@error_history << error
|
|
117
|
+
@error_history.shift while @error_history.size > Constants::MAX_ERROR_HISTORY
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def update_precision(domain, error_magnitude)
|
|
121
|
+
current = @precisions.fetch(domain, Constants::DEFAULT_PRECISION)
|
|
122
|
+
# High error -> precision decreases; low error -> precision increases
|
|
123
|
+
signal = 1.0 - error_magnitude
|
|
124
|
+
updated = ema(current, signal, Constants::PRECISION_ALPHA)
|
|
125
|
+
@precisions[domain] = [updated, Constants::PRECISION_FLOOR].max
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def update_domain_model(domain, actual)
|
|
129
|
+
if @domain_models[domain]
|
|
130
|
+
model = @domain_models[domain]
|
|
131
|
+
model[:count] += 1
|
|
132
|
+
model[:mean] = ema(model[:mean], actual.to_f, Constants::MODEL_LEARNING_RATE)
|
|
133
|
+
else
|
|
134
|
+
@domain_models[domain] = { mean: actual.to_f, count: 1 }
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
return unless @domain_models.size > Constants::MAX_MODELS
|
|
138
|
+
|
|
139
|
+
oldest_domain = @domain_models.keys.first
|
|
140
|
+
@domain_models.delete(oldest_domain)
|
|
141
|
+
@precisions.delete(oldest_domain)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def update_free_energy(error_magnitude)
|
|
145
|
+
complexity = @domain_models.size * Constants::COMPLEXITY_PENALTY
|
|
146
|
+
raw = error_magnitude + complexity
|
|
147
|
+
@free_energy_ema = ema(@free_energy_ema, raw, Constants::FREE_ENERGY_ALPHA)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def average_weighted_error
|
|
151
|
+
return 0.0 if @error_history.empty?
|
|
152
|
+
|
|
153
|
+
recent = @error_history.last(50)
|
|
154
|
+
recent.sum(&:weighted_error) / recent.size
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def recent_errors_for(domain)
|
|
158
|
+
@error_history.select { |e| e.domain == domain }.last(10)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def prune_old_errors
|
|
162
|
+
@error_history.shift while @error_history.size > Constants::MAX_ERROR_HISTORY
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def ema(current, new_value, alpha)
|
|
166
|
+
(alpha * new_value) + ((1.0 - alpha) * current)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def domain_stats
|
|
170
|
+
@domain_models.map do |domain, model|
|
|
171
|
+
{
|
|
172
|
+
domain: domain,
|
|
173
|
+
mean: model[:mean].round(4),
|
|
174
|
+
count: model[:count],
|
|
175
|
+
precision: @precisions.fetch(domain, Constants::DEFAULT_PRECISION).round(4)
|
|
176
|
+
}
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module PredictiveCoding
|
|
6
|
+
module Helpers
|
|
7
|
+
class PredictionError
|
|
8
|
+
attr_reader :domain, :predicted, :actual, :error_magnitude, :precision, :weighted_error, :timestamp
|
|
9
|
+
|
|
10
|
+
def initialize(domain:, predicted:, actual:, precision: Constants::DEFAULT_PRECISION)
|
|
11
|
+
@domain = domain
|
|
12
|
+
@predicted = predicted
|
|
13
|
+
@actual = actual
|
|
14
|
+
@error_magnitude = compute_error_magnitude(predicted, actual)
|
|
15
|
+
@precision = precision
|
|
16
|
+
@weighted_error = @error_magnitude * @precision
|
|
17
|
+
@timestamp = Time.now.utc
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def surprising?
|
|
21
|
+
@error_magnitude >= Constants::SURPRISE_THRESHOLD
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def level
|
|
25
|
+
Constants::PREDICTION_ERROR_LEVELS.find { |_k, range| range.cover?(@error_magnitude) }&.first || :unknown
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def to_h
|
|
29
|
+
{
|
|
30
|
+
domain: @domain,
|
|
31
|
+
predicted: @predicted,
|
|
32
|
+
actual: @actual,
|
|
33
|
+
error_magnitude: @error_magnitude,
|
|
34
|
+
precision: @precision,
|
|
35
|
+
weighted_error: @weighted_error,
|
|
36
|
+
surprising: surprising?,
|
|
37
|
+
level: level,
|
|
38
|
+
timestamp: @timestamp
|
|
39
|
+
}
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def compute_error_magnitude(predicted, actual)
|
|
45
|
+
if predicted.is_a?(Numeric) && actual.is_a?(Numeric)
|
|
46
|
+
(predicted - actual).abs.clamp(0.0, 1.0)
|
|
47
|
+
else
|
|
48
|
+
predicted == actual ? 0.0 : 1.0
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module Extensions
|
|
7
|
+
module PredictiveCoding
|
|
8
|
+
module Runners
|
|
9
|
+
module PredictiveCoding
|
|
10
|
+
include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers) &&
|
|
11
|
+
Legion::Extensions::Helpers.const_defined?(:Lex)
|
|
12
|
+
|
|
13
|
+
def generate_prediction(domain:, context: {}, **)
|
|
14
|
+
prediction = generative_model.predict(domain: domain, context: context)
|
|
15
|
+
Legion::Logging.debug "[predictive_coding] generate_prediction domain=#{domain} " \
|
|
16
|
+
"predicted=#{prediction[:predicted]} confidence=#{prediction[:confidence].round(3)}"
|
|
17
|
+
{ success: true, domain: domain, predicted: prediction[:predicted], confidence: prediction[:confidence] }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def report_outcome(domain:, predicted:, actual:, **)
|
|
21
|
+
error = generative_model.update(domain: domain, predicted: predicted, actual: actual)
|
|
22
|
+
Legion::Logging.debug "[predictive_coding] report_outcome domain=#{domain} " \
|
|
23
|
+
"error_magnitude=#{error.error_magnitude.round(3)} surprising=#{error.surprising?}"
|
|
24
|
+
{
|
|
25
|
+
success: true,
|
|
26
|
+
domain: domain,
|
|
27
|
+
error_magnitude: error.error_magnitude,
|
|
28
|
+
weighted_error: error.weighted_error,
|
|
29
|
+
precision: error.precision,
|
|
30
|
+
surprising: error.surprising?,
|
|
31
|
+
level: error.level
|
|
32
|
+
}
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def precision_for(domain:, **)
|
|
36
|
+
value = generative_model.precision_for(domain: domain)
|
|
37
|
+
Legion::Logging.debug "[predictive_coding] precision_for domain=#{domain} precision=#{value.round(3)}"
|
|
38
|
+
{ success: true, domain: domain, precision: value }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def surprising_errors(**)
|
|
42
|
+
errors = generative_model.surprising_errors
|
|
43
|
+
Legion::Logging.debug "[predictive_coding] surprising_errors count=#{errors.size}"
|
|
44
|
+
{ success: true, errors: errors.map(&:to_h), count: errors.size }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def free_energy_status(**)
|
|
48
|
+
fe = generative_model.free_energy
|
|
49
|
+
level = generative_model.free_energy_level
|
|
50
|
+
Legion::Logging.debug "[predictive_coding] free_energy_status fe=#{fe.round(3)} level=#{level}"
|
|
51
|
+
{
|
|
52
|
+
success: true,
|
|
53
|
+
free_energy: fe,
|
|
54
|
+
level: level,
|
|
55
|
+
model_stats: generative_model.to_h
|
|
56
|
+
}
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def active_inference_candidates(**)
|
|
60
|
+
candidates = generative_model.active_inference_candidates
|
|
61
|
+
Legion::Logging.debug "[predictive_coding] active_inference_candidates count=#{candidates.size}"
|
|
62
|
+
{ success: true, candidates: candidates, count: candidates.size }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def register_active_inference(domain:, action:, expected_outcome:, **)
|
|
66
|
+
inference_id = SecureRandom.uuid
|
|
67
|
+
active_inferences[inference_id] = {
|
|
68
|
+
inference_id: inference_id,
|
|
69
|
+
domain: domain,
|
|
70
|
+
action: action,
|
|
71
|
+
expected_outcome: expected_outcome,
|
|
72
|
+
status: :pending,
|
|
73
|
+
registered_at: Time.now.utc
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
prune_active_inferences
|
|
77
|
+
|
|
78
|
+
Legion::Logging.debug "[predictive_coding] register_active_inference domain=#{domain} id=#{inference_id[0..7]}"
|
|
79
|
+
{ success: true, inference_id: inference_id, domain: domain, status: :pending }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def resolve_active_inference(domain:, action:, actual_outcome:, inference_id: nil, **)
|
|
83
|
+
record = find_inference(domain, action, inference_id)
|
|
84
|
+
unless record
|
|
85
|
+
Legion::Logging.debug "[predictive_coding] resolve_active_inference not found domain=#{domain}"
|
|
86
|
+
return { success: false, reason: :not_found }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
expected = record[:expected_outcome]
|
|
90
|
+
error = generative_model.update(
|
|
91
|
+
domain: domain,
|
|
92
|
+
predicted: expected,
|
|
93
|
+
actual: actual_outcome
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
record[:status] = :resolved
|
|
97
|
+
record[:actual_outcome] = actual_outcome
|
|
98
|
+
record[:resolved_at] = Time.now.utc
|
|
99
|
+
record[:error_magnitude] = error.error_magnitude
|
|
100
|
+
|
|
101
|
+
Legion::Logging.info "[predictive_coding] resolve_active_inference domain=#{domain} " \
|
|
102
|
+
"error=#{error.error_magnitude.round(3)} id=#{record[:inference_id][0..7]}"
|
|
103
|
+
|
|
104
|
+
{
|
|
105
|
+
success: true,
|
|
106
|
+
inference_id: record[:inference_id],
|
|
107
|
+
domain: domain,
|
|
108
|
+
error_magnitude: error.error_magnitude,
|
|
109
|
+
action_helpful: error.error_magnitude < Legion::Extensions::PredictiveCoding::Helpers::Constants::SURPRISE_THRESHOLD
|
|
110
|
+
}
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def update_predictive_coding(**)
|
|
114
|
+
generative_model.decay_all
|
|
115
|
+
pruned = prune_resolved_inferences
|
|
116
|
+
Legion::Logging.debug "[predictive_coding] update_predictive_coding pruned_inferences=#{pruned}"
|
|
117
|
+
{ success: true, pruned_inferences: pruned }
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def predictive_coding_stats(**)
|
|
121
|
+
{
|
|
122
|
+
success: true,
|
|
123
|
+
model: generative_model.to_h,
|
|
124
|
+
active_inferences: active_inferences.size,
|
|
125
|
+
pending_inferences: active_inferences.count { |_, v| v[:status] == :pending }
|
|
126
|
+
}
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
private
|
|
130
|
+
|
|
131
|
+
def generative_model
|
|
132
|
+
@generative_model ||= Helpers::GenerativeModel.new
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def active_inferences
|
|
136
|
+
@active_inferences ||= {}
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def prune_active_inferences
|
|
140
|
+
max = Legion::Extensions::PredictiveCoding::Helpers::Constants::MAX_ACTIVE_INFERENCES
|
|
141
|
+
return unless active_inferences.size > max
|
|
142
|
+
|
|
143
|
+
sorted = active_inferences.sort_by { |_, v| v[:registered_at] }
|
|
144
|
+
ids = sorted.first(active_inferences.size - max).map(&:first)
|
|
145
|
+
ids.each { |id| active_inferences.delete(id) }
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def prune_resolved_inferences
|
|
149
|
+
resolved = active_inferences.select { |_, v| v[:status] == :resolved }.keys
|
|
150
|
+
resolved.each { |id| active_inferences.delete(id) }
|
|
151
|
+
resolved.size
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def find_inference(domain, action, inference_id)
|
|
155
|
+
if inference_id
|
|
156
|
+
active_inferences[inference_id]
|
|
157
|
+
else
|
|
158
|
+
active_inferences.values.find do |r|
|
|
159
|
+
r[:domain] == domain && r[:action] == action && r[:status] == :pending
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
require 'legion/extensions/predictive_coding/version'
|
|
5
|
+
require 'legion/extensions/predictive_coding/helpers/constants'
|
|
6
|
+
require 'legion/extensions/predictive_coding/helpers/prediction_error'
|
|
7
|
+
require 'legion/extensions/predictive_coding/helpers/generative_model'
|
|
8
|
+
require 'legion/extensions/predictive_coding/runners/predictive_coding'
|
|
9
|
+
|
|
10
|
+
module Legion
|
|
11
|
+
module Extensions
|
|
12
|
+
module PredictiveCoding
|
|
13
|
+
extend Legion::Extensions::Core if Legion::Extensions.const_defined? :Core
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/predictive_coding/client'
|
|
4
|
+
|
|
5
|
+
RSpec.describe Legion::Extensions::PredictiveCoding::Client do
|
|
6
|
+
subject(:client) { described_class.new }
|
|
7
|
+
|
|
8
|
+
it 'responds to all runner methods' do
|
|
9
|
+
expect(client).to respond_to(:generate_prediction)
|
|
10
|
+
expect(client).to respond_to(:report_outcome)
|
|
11
|
+
expect(client).to respond_to(:precision_for)
|
|
12
|
+
expect(client).to respond_to(:surprising_errors)
|
|
13
|
+
expect(client).to respond_to(:free_energy_status)
|
|
14
|
+
expect(client).to respond_to(:active_inference_candidates)
|
|
15
|
+
expect(client).to respond_to(:register_active_inference)
|
|
16
|
+
expect(client).to respond_to(:resolve_active_inference)
|
|
17
|
+
expect(client).to respond_to(:update_predictive_coding)
|
|
18
|
+
expect(client).to respond_to(:predictive_coding_stats)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
it 'accepts an injected generative_model' do
|
|
22
|
+
model = Legion::Extensions::PredictiveCoding::Helpers::GenerativeModel.new
|
|
23
|
+
custom_client = described_class.new(generative_model: model)
|
|
24
|
+
expect(custom_client).to respond_to(:generate_prediction)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
it 'isolates state between separate client instances' do
|
|
28
|
+
client_a = described_class.new
|
|
29
|
+
client_b = described_class.new
|
|
30
|
+
|
|
31
|
+
client_a.report_outcome(domain: :x, predicted: 0.0, actual: 1.0)
|
|
32
|
+
result_b = client_b.surprising_errors
|
|
33
|
+
|
|
34
|
+
expect(result_b[:count]).to eq(0)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
describe 'full predictive coding lifecycle' do
|
|
38
|
+
it 'predicts, reports outcome, then updates stats correctly' do
|
|
39
|
+
prediction = client.generate_prediction(domain: :proprioception, context: { expected: 0.6 })
|
|
40
|
+
expect(prediction[:success]).to be true
|
|
41
|
+
|
|
42
|
+
outcome = client.report_outcome(domain: :proprioception, predicted: prediction[:predicted], actual: 0.65)
|
|
43
|
+
expect(outcome[:success]).to be true
|
|
44
|
+
|
|
45
|
+
status = client.free_energy_status
|
|
46
|
+
expect(status[:free_energy]).to be_a(Float)
|
|
47
|
+
|
|
48
|
+
stats = client.predictive_coding_stats
|
|
49
|
+
expect(stats[:model][:domain_count]).to eq(1)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
it 'runs a full active inference cycle' do
|
|
53
|
+
reg = client.register_active_inference(
|
|
54
|
+
domain: :motor_cortex,
|
|
55
|
+
action: :amplify_signal,
|
|
56
|
+
expected_outcome: 0.75
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
expect(reg[:status]).to eq(:pending)
|
|
60
|
+
|
|
61
|
+
resolved = client.resolve_active_inference(
|
|
62
|
+
domain: :motor_cortex,
|
|
63
|
+
action: :amplify_signal,
|
|
64
|
+
actual_outcome: 0.78,
|
|
65
|
+
inference_id: reg[:inference_id]
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
expect(resolved[:success]).to be true
|
|
69
|
+
|
|
70
|
+
after_update = client.update_predictive_coding
|
|
71
|
+
expect(after_update[:pruned_inferences]).to eq(1)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe Legion::Extensions::PredictiveCoding::Helpers::GenerativeModel do
|
|
4
|
+
let(:model) { described_class.new }
|
|
5
|
+
let(:constants) { Legion::Extensions::PredictiveCoding::Helpers::Constants }
|
|
6
|
+
|
|
7
|
+
describe '#initialize' do
|
|
8
|
+
it 'assigns a model_id' do
|
|
9
|
+
expect(model.model_id).to match(/\A[0-9a-f-]{36}\z/)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
it 'starts with zero domain models' do
|
|
13
|
+
expect(model.domain_count).to eq(0)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
it 'starts with zero errors' do
|
|
17
|
+
expect(model.error_count).to eq(0)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
it 'accepts an explicit model_id' do
|
|
21
|
+
m = described_class.new(model_id: 'custom-id')
|
|
22
|
+
expect(m.model_id).to eq('custom-id')
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
describe '#predict' do
|
|
27
|
+
it 'returns a prediction hash with domain and confidence' do
|
|
28
|
+
result = model.predict(domain: :vision)
|
|
29
|
+
expect(result[:domain]).to eq(:vision)
|
|
30
|
+
expect(result[:predicted]).to be_a(Numeric)
|
|
31
|
+
expect(result[:confidence]).to be_a(Float)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
it 'uses DEFAULT_PRECISION for an unknown domain' do
|
|
35
|
+
result = model.predict(domain: :new_domain)
|
|
36
|
+
expect(result[:confidence]).to eq(constants::DEFAULT_PRECISION)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
it 'uses context[:expected] as initial value for unknown domain' do
|
|
40
|
+
result = model.predict(domain: :touch, context: { expected: 0.9 })
|
|
41
|
+
expect(result[:predicted]).to be_within(0.001).of(0.9)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
it 'improves prediction after learning from updates' do
|
|
45
|
+
5.times { model.update(domain: :sensor, predicted: 0.5, actual: 0.8) }
|
|
46
|
+
result = model.predict(domain: :sensor)
|
|
47
|
+
expect(result[:predicted]).to be > 0.5
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
describe '#update' do
|
|
52
|
+
it 'returns a PredictionError object' do
|
|
53
|
+
error = model.update(domain: :vision, predicted: 0.5, actual: 0.9)
|
|
54
|
+
expect(error).to be_a(Legion::Extensions::PredictiveCoding::Helpers::PredictionError)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
it 'increments error count' do
|
|
58
|
+
model.update(domain: :vision, predicted: 0.5, actual: 0.9)
|
|
59
|
+
expect(model.error_count).to eq(1)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
it 'creates a domain model entry' do
|
|
63
|
+
model.update(domain: :vision, predicted: 0.5, actual: 0.9)
|
|
64
|
+
expect(model.domain_count).to eq(1)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
it 'adjusts precision downward on high error' do
|
|
68
|
+
initial = model.precision_for(domain: :vision)
|
|
69
|
+
model.update(domain: :vision, predicted: 0.0, actual: 1.0)
|
|
70
|
+
expect(model.precision_for(domain: :vision)).to be < initial
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
it 'adjusts precision upward on zero error' do
|
|
74
|
+
# First update to initialize the domain
|
|
75
|
+
model.update(domain: :vision, predicted: 0.5, actual: 0.5)
|
|
76
|
+
# Second perfect prediction should raise precision
|
|
77
|
+
after_init = model.precision_for(domain: :vision)
|
|
78
|
+
model.update(domain: :vision, predicted: 0.5, actual: 0.5)
|
|
79
|
+
expect(model.precision_for(domain: :vision)).to be >= after_init
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
describe '#precision_for' do
|
|
84
|
+
it 'returns DEFAULT_PRECISION for unknown domain' do
|
|
85
|
+
expect(model.precision_for(domain: :unknown)).to eq(constants::DEFAULT_PRECISION)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
it 'returns updated precision after updates' do
|
|
89
|
+
model.update(domain: :motor, predicted: 0.5, actual: 0.5)
|
|
90
|
+
expect(model.precision_for(domain: :motor)).to be_a(Float)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
describe '#free_energy' do
|
|
95
|
+
it 'returns a float' do
|
|
96
|
+
expect(model.free_energy).to be_a(Float)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
it 'increases after surprising errors' do
|
|
100
|
+
initial = model.free_energy
|
|
101
|
+
5.times { model.update(domain: :x, predicted: 0.0, actual: 1.0) }
|
|
102
|
+
expect(model.free_energy).to be > initial
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
describe '#free_energy_level' do
|
|
107
|
+
it 'returns a symbol' do
|
|
108
|
+
expect(model.free_energy_level).to be_a(Symbol)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
it 'returns :minimal for a fresh model' do
|
|
112
|
+
expect(model.free_energy_level).to eq(:minimal)
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
describe '#active_inference_candidates' do
|
|
117
|
+
it 'returns an empty array for a fresh model' do
|
|
118
|
+
expect(model.active_inference_candidates).to eq([])
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
it 'returns domains with high error and low precision' do
|
|
122
|
+
10.times { model.update(domain: :faulty, predicted: 0.0, actual: 1.0) }
|
|
123
|
+
candidates = model.active_inference_candidates
|
|
124
|
+
expect(candidates).to include(:faulty)
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
describe '#surprising_errors' do
|
|
129
|
+
it 'returns an empty array when no surprising errors' do
|
|
130
|
+
model.update(domain: :x, predicted: 0.5, actual: 0.5)
|
|
131
|
+
expect(model.surprising_errors).to be_empty
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
it 'returns errors above the surprise threshold' do
|
|
135
|
+
model.update(domain: :x, predicted: 0.0, actual: 1.0)
|
|
136
|
+
expect(model.surprising_errors).not_to be_empty
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
describe '#decay_all' do
|
|
141
|
+
it 'decreases precision for all known domains' do
|
|
142
|
+
model.update(domain: :vision, predicted: 0.8, actual: 0.9)
|
|
143
|
+
before = model.precision_for(domain: :vision)
|
|
144
|
+
model.decay_all
|
|
145
|
+
expect(model.precision_for(domain: :vision)).to be <= before
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
it 'does not drop precision below PRECISION_FLOOR' do
|
|
149
|
+
50.times { model.decay_all }
|
|
150
|
+
model.update(domain: :x, predicted: 0.5, actual: 0.5)
|
|
151
|
+
50.times { model.decay_all }
|
|
152
|
+
expect(model.precision_for(domain: :x)).to be >= constants::PRECISION_FLOOR
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
describe '#to_h' do
|
|
157
|
+
it 'returns a summary hash' do
|
|
158
|
+
h = model.to_h
|
|
159
|
+
expect(h[:model_id]).to eq(model.model_id)
|
|
160
|
+
expect(h[:domain_count]).to be_a(Integer)
|
|
161
|
+
expect(h[:error_count]).to be_a(Integer)
|
|
162
|
+
expect(h[:free_energy]).to be_a(Float)
|
|
163
|
+
expect(h[:free_energy_level]).to be_a(Symbol)
|
|
164
|
+
expect(h[:domains]).to be_an(Array)
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
it 'includes domain stats after updates' do
|
|
168
|
+
model.update(domain: :vision, predicted: 0.5, actual: 0.7)
|
|
169
|
+
h = model.to_h
|
|
170
|
+
expect(h[:domain_count]).to eq(1)
|
|
171
|
+
vision_stat = h[:domains].find { |d| d[:domain] == :vision }
|
|
172
|
+
expect(vision_stat).not_to be_nil
|
|
173
|
+
expect(vision_stat[:mean]).to be_a(Float)
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
describe 'MAX_MODELS eviction' do
|
|
178
|
+
it 'does not exceed MAX_MODELS domains' do
|
|
179
|
+
(constants::MAX_MODELS + 5).times do |i|
|
|
180
|
+
model.update(domain: :"domain_#{i}", predicted: 0.5, actual: 0.5)
|
|
181
|
+
end
|
|
182
|
+
expect(model.domain_count).to be <= constants::MAX_MODELS
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
describe 'MAX_ERROR_HISTORY cap' do
|
|
187
|
+
it 'caps error history at MAX_ERROR_HISTORY' do
|
|
188
|
+
(constants::MAX_ERROR_HISTORY + 10).times do
|
|
189
|
+
model.update(domain: :x, predicted: 0.5, actual: 0.5)
|
|
190
|
+
end
|
|
191
|
+
expect(model.error_count).to be <= constants::MAX_ERROR_HISTORY
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe Legion::Extensions::PredictiveCoding::Helpers::PredictionError do
|
|
4
|
+
let(:constants) { Legion::Extensions::PredictiveCoding::Helpers::Constants }
|
|
5
|
+
|
|
6
|
+
describe '#initialize' do
|
|
7
|
+
it 'stores domain, predicted, actual' do
|
|
8
|
+
error = described_class.new(domain: :vision, predicted: 0.8, actual: 0.9)
|
|
9
|
+
expect(error.domain).to eq(:vision)
|
|
10
|
+
expect(error.predicted).to eq(0.8)
|
|
11
|
+
expect(error.actual).to eq(0.9)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
it 'stores a timestamp' do
|
|
15
|
+
error = described_class.new(domain: :vision, predicted: 0.5, actual: 0.5)
|
|
16
|
+
expect(error.timestamp).to be_a(Time)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
it 'uses DEFAULT_PRECISION when none provided' do
|
|
20
|
+
error = described_class.new(domain: :vision, predicted: 0.5, actual: 0.5)
|
|
21
|
+
expect(error.precision).to eq(constants::DEFAULT_PRECISION)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
it 'accepts explicit precision' do
|
|
25
|
+
error = described_class.new(domain: :vision, predicted: 0.5, actual: 0.5, precision: 0.9)
|
|
26
|
+
expect(error.precision).to eq(0.9)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
describe '#error_magnitude' do
|
|
31
|
+
it 'computes absolute difference for numeric values' do
|
|
32
|
+
error = described_class.new(domain: :x, predicted: 0.3, actual: 0.8)
|
|
33
|
+
expect(error.error_magnitude).to be_within(0.001).of(0.5)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
it 'clamps error magnitude to 1.0 for values differing by more than 1' do
|
|
37
|
+
error = described_class.new(domain: :x, predicted: 0.0, actual: 2.0)
|
|
38
|
+
expect(error.error_magnitude).to eq(1.0)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
it 'returns 0.0 when predicted equals actual for numeric values' do
|
|
42
|
+
error = described_class.new(domain: :x, predicted: 0.5, actual: 0.5)
|
|
43
|
+
expect(error.error_magnitude).to eq(0.0)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
it 'returns 0.0 when predicted equals actual for non-numeric values' do
|
|
47
|
+
error = described_class.new(domain: :x, predicted: :foo, actual: :foo)
|
|
48
|
+
expect(error.error_magnitude).to eq(0.0)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
it 'returns 1.0 when non-numeric predicted differs from actual' do
|
|
52
|
+
error = described_class.new(domain: :x, predicted: :foo, actual: :bar)
|
|
53
|
+
expect(error.error_magnitude).to eq(1.0)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
describe '#weighted_error' do
|
|
58
|
+
it 'equals error_magnitude * precision' do
|
|
59
|
+
error = described_class.new(domain: :x, predicted: 0.2, actual: 0.6, precision: 0.8)
|
|
60
|
+
expected = (0.6 - 0.2).abs * 0.8
|
|
61
|
+
expect(error.weighted_error).to be_within(0.001).of(expected)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
describe '#surprising?' do
|
|
66
|
+
it 'returns true when error_magnitude >= SURPRISE_THRESHOLD' do
|
|
67
|
+
error = described_class.new(domain: :x, predicted: 0.0, actual: 1.0)
|
|
68
|
+
expect(error.surprising?).to be true
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
it 'returns false when error_magnitude < SURPRISE_THRESHOLD' do
|
|
72
|
+
error = described_class.new(domain: :x, predicted: 0.5, actual: 0.55)
|
|
73
|
+
expect(error.surprising?).to be false
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
describe '#level' do
|
|
78
|
+
it 'returns :negligible for very small errors' do
|
|
79
|
+
error = described_class.new(domain: :x, predicted: 0.5, actual: 0.505)
|
|
80
|
+
expect(error.level).to eq(:negligible)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
it 'returns :surprising for large errors' do
|
|
84
|
+
error = described_class.new(domain: :x, predicted: 0.0, actual: 1.0)
|
|
85
|
+
expect(error.level).to eq(:surprising)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
it 'returns :moderate for mid-range errors' do
|
|
89
|
+
error = described_class.new(domain: :x, predicted: 0.0, actual: 0.4)
|
|
90
|
+
expect(error.level).to eq(:moderate)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
describe '#to_h' do
|
|
95
|
+
it 'returns a hash with all fields' do
|
|
96
|
+
error = described_class.new(domain: :vision, predicted: 0.3, actual: 0.7, precision: 0.6)
|
|
97
|
+
h = error.to_h
|
|
98
|
+
expect(h[:domain]).to eq(:vision)
|
|
99
|
+
expect(h[:predicted]).to eq(0.3)
|
|
100
|
+
expect(h[:actual]).to eq(0.7)
|
|
101
|
+
expect(h[:error_magnitude]).to be_a(Float)
|
|
102
|
+
expect(h[:precision]).to eq(0.6)
|
|
103
|
+
expect(h[:weighted_error]).to be_a(Float)
|
|
104
|
+
expect(h[:surprising]).to be(true).or be(false)
|
|
105
|
+
expect(h[:level]).to be_a(Symbol)
|
|
106
|
+
expect(h[:timestamp]).to be_a(Time)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/predictive_coding/client'
|
|
4
|
+
|
|
5
|
+
RSpec.describe Legion::Extensions::PredictiveCoding::Runners::PredictiveCoding do
|
|
6
|
+
let(:client) { Legion::Extensions::PredictiveCoding::Client.new }
|
|
7
|
+
|
|
8
|
+
describe '#generate_prediction' do
|
|
9
|
+
it 'returns success with domain and prediction' do
|
|
10
|
+
result = client.generate_prediction(domain: :vision)
|
|
11
|
+
expect(result[:success]).to be true
|
|
12
|
+
expect(result[:domain]).to eq(:vision)
|
|
13
|
+
expect(result[:predicted]).to be_a(Numeric)
|
|
14
|
+
expect(result[:confidence]).to be_a(Float)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
it 'passes context to the generative model' do
|
|
18
|
+
result = client.generate_prediction(domain: :audio, context: { expected: 0.7 })
|
|
19
|
+
expect(result[:success]).to be true
|
|
20
|
+
expect(result[:predicted]).to be_within(0.001).of(0.7)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
it 'accepts extra keyword arguments without error' do
|
|
24
|
+
expect { client.generate_prediction(domain: :x, extra: :ignored) }.not_to raise_error
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
describe '#report_outcome' do
|
|
29
|
+
it 'returns success with error details' do
|
|
30
|
+
result = client.report_outcome(domain: :vision, predicted: 0.5, actual: 0.9)
|
|
31
|
+
expect(result[:success]).to be true
|
|
32
|
+
expect(result[:error_magnitude]).to be_a(Float)
|
|
33
|
+
expect(result[:weighted_error]).to be_a(Float)
|
|
34
|
+
expect(result[:precision]).to be_a(Float)
|
|
35
|
+
expect(result[:surprising]).to be(true).or be(false)
|
|
36
|
+
expect(result[:level]).to be_a(Symbol)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
it 'marks zero-error outcomes as non-surprising' do
|
|
40
|
+
result = client.report_outcome(domain: :x, predicted: 0.5, actual: 0.5)
|
|
41
|
+
expect(result[:surprising]).to be false
|
|
42
|
+
expect(result[:error_magnitude]).to eq(0.0)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
it 'marks large-error outcomes as surprising' do
|
|
46
|
+
result = client.report_outcome(domain: :x, predicted: 0.0, actual: 1.0)
|
|
47
|
+
expect(result[:surprising]).to be true
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
describe '#precision_for' do
|
|
52
|
+
it 'returns DEFAULT_PRECISION for unknown domain' do
|
|
53
|
+
result = client.precision_for(domain: :unknown_domain_xyz)
|
|
54
|
+
expect(result[:success]).to be true
|
|
55
|
+
expect(result[:precision]).to eq(Legion::Extensions::PredictiveCoding::Helpers::Constants::DEFAULT_PRECISION)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
it 'returns updated precision after reporting outcomes' do
|
|
59
|
+
client.report_outcome(domain: :sensor, predicted: 0.5, actual: 0.5)
|
|
60
|
+
result = client.precision_for(domain: :sensor)
|
|
61
|
+
expect(result[:precision]).to be_a(Float)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
describe '#surprising_errors' do
|
|
66
|
+
it 'returns empty list when no surprising errors' do
|
|
67
|
+
result = client.surprising_errors
|
|
68
|
+
expect(result[:success]).to be true
|
|
69
|
+
expect(result[:errors]).to be_an(Array)
|
|
70
|
+
expect(result[:count]).to eq(0)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
it 'returns errors above surprise threshold' do
|
|
74
|
+
client.report_outcome(domain: :x, predicted: 0.0, actual: 1.0)
|
|
75
|
+
result = client.surprising_errors
|
|
76
|
+
expect(result[:count]).to be >= 1
|
|
77
|
+
expect(result[:errors].first[:surprising]).to be true
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
describe '#free_energy_status' do
|
|
82
|
+
it 'returns free energy value and level' do
|
|
83
|
+
result = client.free_energy_status
|
|
84
|
+
expect(result[:success]).to be true
|
|
85
|
+
expect(result[:free_energy]).to be_a(Float)
|
|
86
|
+
expect(result[:level]).to be_a(Symbol)
|
|
87
|
+
expect(result[:model_stats]).to be_a(Hash)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
it 'reflects accumulated errors in free energy' do
|
|
91
|
+
initial = client.free_energy_status[:free_energy]
|
|
92
|
+
5.times { client.report_outcome(domain: :x, predicted: 0.0, actual: 1.0) }
|
|
93
|
+
elevated = client.free_energy_status[:free_energy]
|
|
94
|
+
expect(elevated).to be > initial
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
describe '#active_inference_candidates' do
|
|
99
|
+
it 'returns empty list for fresh client' do
|
|
100
|
+
result = client.active_inference_candidates
|
|
101
|
+
expect(result[:success]).to be true
|
|
102
|
+
expect(result[:candidates]).to be_an(Array)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
it 'includes domains with persistent high errors' do
|
|
106
|
+
10.times { client.report_outcome(domain: :faulty, predicted: 0.0, actual: 1.0) }
|
|
107
|
+
result = client.active_inference_candidates
|
|
108
|
+
expect(result[:candidates]).to include(:faulty)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
describe '#register_active_inference' do
|
|
113
|
+
it 'registers and returns an inference_id' do
|
|
114
|
+
result = client.register_active_inference(domain: :motor, action: :increase_gain, expected_outcome: 0.8)
|
|
115
|
+
expect(result[:success]).to be true
|
|
116
|
+
expect(result[:inference_id]).to match(/\A[0-9a-f-]{36}\z/)
|
|
117
|
+
expect(result[:status]).to eq(:pending)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
it 'accepts extra keyword arguments' do
|
|
121
|
+
expect do
|
|
122
|
+
client.register_active_inference(domain: :x, action: :test, expected_outcome: 0.5, extra: :ignored)
|
|
123
|
+
end.not_to raise_error
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
describe '#resolve_active_inference' do
|
|
128
|
+
it 'resolves a registered inference and returns error details' do
|
|
129
|
+
reg = client.register_active_inference(domain: :motor, action: :increase_gain, expected_outcome: 0.8)
|
|
130
|
+
result = client.resolve_active_inference(
|
|
131
|
+
domain: :motor,
|
|
132
|
+
action: :increase_gain,
|
|
133
|
+
actual_outcome: 0.85,
|
|
134
|
+
inference_id: reg[:inference_id]
|
|
135
|
+
)
|
|
136
|
+
expect(result[:success]).to be true
|
|
137
|
+
expect(result[:error_magnitude]).to be_a(Float)
|
|
138
|
+
expect(result).to have_key(:action_helpful)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
it 'returns not_found when inference does not exist' do
|
|
142
|
+
result = client.resolve_active_inference(
|
|
143
|
+
domain: :missing,
|
|
144
|
+
action: :noop,
|
|
145
|
+
actual_outcome: 0.5,
|
|
146
|
+
inference_id: 'nonexistent-id'
|
|
147
|
+
)
|
|
148
|
+
expect(result[:success]).to be false
|
|
149
|
+
expect(result[:reason]).to eq(:not_found)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
it 'marks action_helpful true when error is below surprise threshold' do
|
|
153
|
+
reg = client.register_active_inference(domain: :x, action: :nudge, expected_outcome: 0.5)
|
|
154
|
+
result = client.resolve_active_inference(
|
|
155
|
+
domain: :x,
|
|
156
|
+
action: :nudge,
|
|
157
|
+
actual_outcome: 0.51,
|
|
158
|
+
inference_id: reg[:inference_id]
|
|
159
|
+
)
|
|
160
|
+
expect(result[:action_helpful]).to be true
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
it 'marks action_helpful false when error is above surprise threshold' do
|
|
164
|
+
reg = client.register_active_inference(domain: :x, action: :nudge, expected_outcome: 0.0)
|
|
165
|
+
result = client.resolve_active_inference(
|
|
166
|
+
domain: :x,
|
|
167
|
+
action: :nudge,
|
|
168
|
+
actual_outcome: 1.0,
|
|
169
|
+
inference_id: reg[:inference_id]
|
|
170
|
+
)
|
|
171
|
+
expect(result[:action_helpful]).to be false
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
describe '#update_predictive_coding' do
|
|
176
|
+
it 'returns success' do
|
|
177
|
+
result = client.update_predictive_coding
|
|
178
|
+
expect(result[:success]).to be true
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
it 'prunes resolved inferences' do
|
|
182
|
+
reg = client.register_active_inference(domain: :x, action: :test, expected_outcome: 0.5)
|
|
183
|
+
client.resolve_active_inference(domain: :x, action: :test, actual_outcome: 0.5, inference_id: reg[:inference_id])
|
|
184
|
+
result = client.update_predictive_coding
|
|
185
|
+
expect(result[:pruned_inferences]).to be >= 1
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
it 'returns pruned count of zero when nothing resolved' do
|
|
189
|
+
result = client.update_predictive_coding
|
|
190
|
+
expect(result[:pruned_inferences]).to eq(0)
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
describe '#predictive_coding_stats' do
|
|
195
|
+
it 'returns full stats hash' do
|
|
196
|
+
result = client.predictive_coding_stats
|
|
197
|
+
expect(result[:success]).to be true
|
|
198
|
+
expect(result[:model]).to be_a(Hash)
|
|
199
|
+
expect(result[:active_inferences]).to be_a(Integer)
|
|
200
|
+
expect(result[:pending_inferences]).to be_a(Integer)
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
it 'counts active inferences correctly' do
|
|
204
|
+
client.register_active_inference(domain: :x, action: :test, expected_outcome: 0.5)
|
|
205
|
+
result = client.predictive_coding_stats
|
|
206
|
+
expect(result[:active_inferences]).to eq(1)
|
|
207
|
+
expect(result[:pending_inferences]).to eq(1)
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
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/predictive_coding'
|
|
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-predictive-coding
|
|
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: Karl Friston's Free Energy Principle / Predictive Processing framework
|
|
27
|
+
for brain-modeled agentic AI
|
|
28
|
+
email:
|
|
29
|
+
- matthewdiverson@gmail.com
|
|
30
|
+
executables: []
|
|
31
|
+
extensions: []
|
|
32
|
+
extra_rdoc_files: []
|
|
33
|
+
files:
|
|
34
|
+
- Gemfile
|
|
35
|
+
- lex-predictive-coding.gemspec
|
|
36
|
+
- lib/legion/extensions/predictive_coding.rb
|
|
37
|
+
- lib/legion/extensions/predictive_coding/actors/decay.rb
|
|
38
|
+
- lib/legion/extensions/predictive_coding/client.rb
|
|
39
|
+
- lib/legion/extensions/predictive_coding/helpers/constants.rb
|
|
40
|
+
- lib/legion/extensions/predictive_coding/helpers/generative_model.rb
|
|
41
|
+
- lib/legion/extensions/predictive_coding/helpers/prediction_error.rb
|
|
42
|
+
- lib/legion/extensions/predictive_coding/runners/predictive_coding.rb
|
|
43
|
+
- lib/legion/extensions/predictive_coding/version.rb
|
|
44
|
+
- spec/legion/extensions/predictive_coding/client_spec.rb
|
|
45
|
+
- spec/legion/extensions/predictive_coding/helpers/generative_model_spec.rb
|
|
46
|
+
- spec/legion/extensions/predictive_coding/helpers/prediction_error_spec.rb
|
|
47
|
+
- spec/legion/extensions/predictive_coding/runners/predictive_coding_spec.rb
|
|
48
|
+
- spec/spec_helper.rb
|
|
49
|
+
homepage: https://github.com/LegionIO/lex-predictive-coding
|
|
50
|
+
licenses:
|
|
51
|
+
- MIT
|
|
52
|
+
metadata:
|
|
53
|
+
homepage_uri: https://github.com/LegionIO/lex-predictive-coding
|
|
54
|
+
source_code_uri: https://github.com/LegionIO/lex-predictive-coding
|
|
55
|
+
documentation_uri: https://github.com/LegionIO/lex-predictive-coding
|
|
56
|
+
changelog_uri: https://github.com/LegionIO/lex-predictive-coding
|
|
57
|
+
bug_tracker_uri: https://github.com/LegionIO/lex-predictive-coding/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 Coding
|
|
76
|
+
test_files: []
|