lex-prediction 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 40883318ee6a41596e8b2693ba9c154d2191b8566ad39fea5d45c00a4a87d2de
4
+ data.tar.gz: f75fffc950c49b188aa8904d560e225c70965dd27c8f83874dbf6f7879e7ea0d
5
+ SHA512:
6
+ metadata.gz: a49a26d11921d15af3613124205478263fc836ca7ce58adcf4506e4de69c3762fe748bfbb6a9a232c2a218bc114891fde42632a2ca623c0ddf3931bd2b864f64
7
+ data.tar.gz: 2fb9dfe8318de27742d9a67f17145abe63a2b069ef9eeef0be776069dfa672233848d76eddab624c750467bd8e99fb446c154821351fc4b0f6be78677b95079c
data/Gemfile ADDED
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ gemspec
6
+
7
+ gem 'rspec', '~> 3.13'
8
+ gem 'rubocop', '~> 1.75', require: false
9
+ gem 'rubocop-rspec', require: false
10
+
11
+ gem 'legion-gaia', path: '../../legion-gaia'
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'lib/legion/extensions/prediction/version'
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = 'lex-prediction'
7
+ spec.version = Legion::Extensions::Prediction::VERSION
8
+ spec.authors = ['Esity']
9
+ spec.email = ['matthewdiverson@gmail.com']
10
+
11
+ spec.summary = 'LEX Prediction'
12
+ spec.description = 'Forward-model prediction engine (4 reasoning modes) for brain-modeled agentic AI'
13
+ spec.homepage = 'https://github.com/LegionIO/lex-prediction'
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-prediction'
19
+ spec.metadata['documentation_uri'] = 'https://github.com/LegionIO/lex-prediction'
20
+ spec.metadata['changelog_uri'] = 'https://github.com/LegionIO/lex-prediction'
21
+ spec.metadata['bug_tracker_uri'] = 'https://github.com/LegionIO/lex-prediction/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-prediction.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 Prediction
8
+ module Actor
9
+ class ExpirePredictions < Legion::Extensions::Actors::Every
10
+ def runner_class
11
+ Legion::Extensions::Prediction::Runners::Prediction
12
+ end
13
+
14
+ def runner_function
15
+ 'expire_stale_predictions'
16
+ end
17
+
18
+ def time
19
+ 300
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,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/prediction/helpers/modes'
4
+ require 'legion/extensions/prediction/helpers/prediction_store'
5
+ require 'legion/extensions/prediction/runners/prediction'
6
+
7
+ module Legion
8
+ module Extensions
9
+ module Prediction
10
+ class Client
11
+ include Runners::Prediction
12
+
13
+ def initialize(**)
14
+ @prediction_store = Helpers::PredictionStore.new
15
+ end
16
+
17
+ private
18
+
19
+ attr_reader :prediction_store
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Prediction
6
+ module Helpers
7
+ module Modes
8
+ # Four reasoning modes (spec: prediction-engine-spec.md)
9
+ REASONING_MODES = %i[fault_localization functional_mapping boundary_testing counterfactual].freeze
10
+
11
+ PREDICTION_CONFIDENCE_MIN = 0.65
12
+ MAX_PREDICTIONS_PER_TICK = 5
13
+ PREDICTION_HORIZON = 3600 # 1 hour default lookahead
14
+
15
+ module_function
16
+
17
+ def valid_mode?(mode)
18
+ REASONING_MODES.include?(mode)
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module Legion
6
+ module Extensions
7
+ module Prediction
8
+ module Helpers
9
+ class PredictionStore
10
+ attr_reader :predictions, :outcomes
11
+
12
+ def initialize
13
+ @predictions = {}
14
+ @outcomes = []
15
+ end
16
+
17
+ def store(prediction)
18
+ id = prediction[:prediction_id] || SecureRandom.uuid
19
+ prediction[:prediction_id] = id
20
+ prediction[:created_at] ||= Time.now.utc
21
+ prediction[:status] ||= :pending
22
+ @predictions[id] = prediction
23
+ id
24
+ end
25
+
26
+ def get(prediction_id)
27
+ @predictions[prediction_id]
28
+ end
29
+
30
+ def resolve(prediction_id, outcome:, actual: nil)
31
+ pred = @predictions[prediction_id]
32
+ return nil unless pred
33
+
34
+ pred[:status] = outcome # :correct, :incorrect, :partial, :expired
35
+ pred[:resolved_at] = Time.now.utc
36
+ pred[:actual] = actual
37
+
38
+ @outcomes << { prediction_id: prediction_id, outcome: outcome, at: Time.now.utc }
39
+ @outcomes.shift while @outcomes.size > 500
40
+ pred
41
+ end
42
+
43
+ def pending
44
+ @predictions.values.select { |p| p[:status] == :pending }
45
+ end
46
+
47
+ def accuracy(window: 100)
48
+ recent = @outcomes.last(window)
49
+ return 0.0 if recent.empty?
50
+
51
+ correct = recent.count { |o| o[:outcome] == :correct }
52
+ correct.to_f / recent.size
53
+ end
54
+
55
+ def count
56
+ @predictions.size
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module Legion
6
+ module Extensions
7
+ module Prediction
8
+ module Runners
9
+ module Prediction
10
+ include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers) &&
11
+ Legion::Extensions::Helpers.const_defined?(:Lex)
12
+
13
+ def predict(mode:, context: {}, confidence: nil, description: nil, **)
14
+ return { error: :invalid_mode, valid_modes: Helpers::Modes::REASONING_MODES } unless Helpers::Modes.valid_mode?(mode)
15
+
16
+ prediction = {
17
+ prediction_id: SecureRandom.uuid,
18
+ mode: mode,
19
+ context: context,
20
+ confidence: confidence || estimate_confidence(mode, context),
21
+ description: description,
22
+ status: :pending,
23
+ created_at: Time.now.utc,
24
+ horizon: Helpers::Modes::PREDICTION_HORIZON
25
+ }
26
+
27
+ prediction_store.store(prediction)
28
+
29
+ actionable = prediction[:confidence] >= Helpers::Modes::PREDICTION_CONFIDENCE_MIN
30
+ Legion::Logging.debug "[prediction] new: mode=#{mode} confidence=#{prediction[:confidence].round(2)} " \
31
+ "actionable=#{actionable} id=#{prediction[:prediction_id][0..7]}"
32
+
33
+ {
34
+ prediction_id: prediction[:prediction_id],
35
+ mode: mode,
36
+ confidence: prediction[:confidence],
37
+ actionable: actionable
38
+ }
39
+ end
40
+
41
+ def resolve_prediction(prediction_id:, outcome:, actual: nil, **)
42
+ pred = prediction_store.resolve(prediction_id, outcome: outcome, actual: actual)
43
+ if pred
44
+ Legion::Logging.info "[prediction] resolved #{prediction_id[0..7]} outcome=#{outcome}"
45
+ record_outcome_trace(pred, outcome)
46
+ { resolved: true, prediction_id: prediction_id, outcome: outcome }
47
+ else
48
+ Legion::Logging.debug "[prediction] resolve failed: #{prediction_id[0..7]} not found"
49
+ { resolved: false, reason: :not_found }
50
+ end
51
+ end
52
+
53
+ def pending_predictions(**)
54
+ preds = prediction_store.pending
55
+ Legion::Logging.debug "[prediction] pending count=#{preds.size}"
56
+ { predictions: preds, count: preds.size }
57
+ end
58
+
59
+ def prediction_accuracy(window: 100, **)
60
+ acc = prediction_store.accuracy(window: window)
61
+ total = prediction_store.outcomes.size
62
+ Legion::Logging.debug "[prediction] accuracy=#{acc.round(2)} total_outcomes=#{total}"
63
+ { accuracy: acc, total_outcomes: total }
64
+ end
65
+
66
+ def expire_stale_predictions(**)
67
+ expired_count = 0
68
+
69
+ prediction_store.pending.each do |pred|
70
+ age = Time.now.utc - pred[:created_at]
71
+ next unless age > pred[:horizon]
72
+
73
+ prediction_store.resolve(pred[:prediction_id], outcome: :expired, actual: nil)
74
+ expired_count += 1
75
+ end
76
+
77
+ remaining = prediction_store.pending.size
78
+ Legion::Logging.debug "[prediction] expire sweep: expired=#{expired_count} remaining=#{remaining}"
79
+
80
+ { expired_count: expired_count, remaining_pending: remaining }
81
+ end
82
+
83
+ def get_prediction(prediction_id:, **)
84
+ pred = prediction_store.get(prediction_id)
85
+ pred ? { found: true, prediction: pred } : { found: false }
86
+ end
87
+
88
+ private
89
+
90
+ def prediction_store
91
+ @prediction_store ||= Helpers::PredictionStore.new
92
+ end
93
+
94
+ def estimate_confidence(mode, context)
95
+ base = case mode
96
+ when :fault_localization then 0.7
97
+ when :functional_mapping then 0.6
98
+ when :counterfactual then 0.4
99
+ else 0.5
100
+ end
101
+ richness_bonus = [context.size * 0.02, 0.2].min
102
+ [base + richness_bonus, 1.0].min
103
+ end
104
+
105
+ def record_outcome_trace(prediction, outcome)
106
+ return unless defined?(Legion::Extensions::Memory::Runners::Traces)
107
+
108
+ trace_params = case outcome
109
+ when :correct
110
+ { type: :semantic, valence: 0.3, intensity: 0.3, unresolved: false }
111
+ when :incorrect
112
+ { type: :episodic, valence: -0.5, intensity: 0.6, unresolved: true }
113
+ when :partial
114
+ { type: :episodic, valence: -0.2, intensity: 0.4, unresolved: true }
115
+ else
116
+ return
117
+ end
118
+
119
+ runner = Object.new.extend(Legion::Extensions::Memory::Runners::Traces)
120
+ runner.store_trace(
121
+ type: trace_params[:type],
122
+ content_payload: "prediction #{outcome}: mode=#{prediction[:mode]} confidence=#{prediction[:confidence]}",
123
+ domain_tags: ['prediction', prediction[:mode].to_s],
124
+ origin: :direct_experience,
125
+ emotional_valence: trace_params[:valence],
126
+ emotional_intensity: trace_params[:intensity],
127
+ unresolved: trace_params[:unresolved],
128
+ confidence: prediction[:confidence]
129
+ )
130
+
131
+ store = runner.send(:default_store)
132
+ store.flush if store.respond_to?(:flush)
133
+
134
+ Legion::Logging.debug "[prediction] created #{trace_params[:type]} trace for #{outcome} prediction"
135
+ rescue StandardError => e
136
+ Legion::Logging.warn "[prediction] failed to create outcome trace: #{e.message}"
137
+ end
138
+ end
139
+ end
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Prediction
6
+ VERSION = '0.1.1'
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/prediction/version'
4
+ require 'legion/extensions/prediction/helpers/modes'
5
+ require 'legion/extensions/prediction/helpers/prediction_store'
6
+ require 'legion/extensions/prediction/runners/prediction'
7
+
8
+ module Legion
9
+ module Extensions
10
+ module Prediction
11
+ extend Legion::Extensions::Core if Legion::Extensions.const_defined? :Core
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Stub the base class before loading the actor
4
+ module Legion
5
+ module Extensions
6
+ module Actors
7
+ class Every; end # rubocop:disable Lint/EmptyClass
8
+ end
9
+ end
10
+ end
11
+
12
+ $LOADED_FEATURES << 'legion/extensions/actors/every'
13
+
14
+ require_relative '../../../../../lib/legion/extensions/prediction/actors/expire_predictions'
15
+
16
+ RSpec.describe Legion::Extensions::Prediction::Actor::ExpirePredictions do
17
+ subject(:actor) { described_class.new }
18
+
19
+ describe '#runner_class' do
20
+ it { expect(actor.runner_class).to eq Legion::Extensions::Prediction::Runners::Prediction }
21
+ end
22
+
23
+ describe '#runner_function' do
24
+ it { expect(actor.runner_function).to eq 'expire_stale_predictions' }
25
+ end
26
+
27
+ describe '#time' do
28
+ it { expect(actor.time).to eq 300 }
29
+ end
30
+
31
+ describe '#run_now?' do
32
+ it { expect(actor.run_now?).to be false }
33
+ end
34
+
35
+ describe '#use_runner?' do
36
+ it { expect(actor.use_runner?).to be false }
37
+ end
38
+
39
+ describe '#check_subtask?' do
40
+ it { expect(actor.check_subtask?).to be false }
41
+ end
42
+
43
+ describe '#generate_task?' do
44
+ it { expect(actor.generate_task?).to be false }
45
+ end
46
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/prediction/client'
4
+
5
+ RSpec.describe Legion::Extensions::Prediction::Client do
6
+ it 'responds to prediction runner methods' do
7
+ client = described_class.new
8
+ expect(client).to respond_to(:predict)
9
+ expect(client).to respond_to(:resolve_prediction)
10
+ expect(client).to respond_to(:pending_predictions)
11
+ expect(client).to respond_to(:prediction_accuracy)
12
+ expect(client).to respond_to(:get_prediction)
13
+ end
14
+ end
@@ -0,0 +1,118 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe Legion::Extensions::Prediction::Helpers::Modes do
6
+ describe 'REASONING_MODES' do
7
+ it 'is a frozen array of symbols' do
8
+ expect(described_class::REASONING_MODES).to be_a(Array)
9
+ expect(described_class::REASONING_MODES).to be_frozen
10
+ end
11
+
12
+ it 'contains exactly four modes' do
13
+ expect(described_class::REASONING_MODES.size).to eq(4)
14
+ end
15
+
16
+ it 'includes fault_localization' do
17
+ expect(described_class::REASONING_MODES).to include(:fault_localization)
18
+ end
19
+
20
+ it 'includes functional_mapping' do
21
+ expect(described_class::REASONING_MODES).to include(:functional_mapping)
22
+ end
23
+
24
+ it 'includes boundary_testing' do
25
+ expect(described_class::REASONING_MODES).to include(:boundary_testing)
26
+ end
27
+
28
+ it 'includes counterfactual' do
29
+ expect(described_class::REASONING_MODES).to include(:counterfactual)
30
+ end
31
+
32
+ it 'contains only symbols' do
33
+ expect(described_class::REASONING_MODES).to all(be_a(Symbol))
34
+ end
35
+ end
36
+
37
+ describe 'PREDICTION_CONFIDENCE_MIN' do
38
+ it 'is 0.65' do
39
+ expect(described_class::PREDICTION_CONFIDENCE_MIN).to eq(0.65)
40
+ end
41
+
42
+ it 'is a float' do
43
+ expect(described_class::PREDICTION_CONFIDENCE_MIN).to be_a(Float)
44
+ end
45
+
46
+ it 'is in the valid 0.0-1.0 range' do
47
+ expect(described_class::PREDICTION_CONFIDENCE_MIN).to be_between(0.0, 1.0)
48
+ end
49
+ end
50
+
51
+ describe 'MAX_PREDICTIONS_PER_TICK' do
52
+ it 'is 5' do
53
+ expect(described_class::MAX_PREDICTIONS_PER_TICK).to eq(5)
54
+ end
55
+
56
+ it 'is an integer' do
57
+ expect(described_class::MAX_PREDICTIONS_PER_TICK).to be_an(Integer)
58
+ end
59
+
60
+ it 'is positive' do
61
+ expect(described_class::MAX_PREDICTIONS_PER_TICK).to be > 0
62
+ end
63
+ end
64
+
65
+ describe 'PREDICTION_HORIZON' do
66
+ it 'is 3600' do
67
+ expect(described_class::PREDICTION_HORIZON).to eq(3600)
68
+ end
69
+
70
+ it 'is an integer' do
71
+ expect(described_class::PREDICTION_HORIZON).to be_an(Integer)
72
+ end
73
+
74
+ it 'represents one hour in seconds' do
75
+ expect(described_class::PREDICTION_HORIZON).to eq(60 * 60)
76
+ end
77
+ end
78
+
79
+ describe '.valid_mode?' do
80
+ it 'returns true for fault_localization' do
81
+ expect(described_class.valid_mode?(:fault_localization)).to be true
82
+ end
83
+
84
+ it 'returns true for functional_mapping' do
85
+ expect(described_class.valid_mode?(:functional_mapping)).to be true
86
+ end
87
+
88
+ it 'returns true for boundary_testing' do
89
+ expect(described_class.valid_mode?(:boundary_testing)).to be true
90
+ end
91
+
92
+ it 'returns true for counterfactual' do
93
+ expect(described_class.valid_mode?(:counterfactual)).to be true
94
+ end
95
+
96
+ it 'returns false for an unknown mode symbol' do
97
+ expect(described_class.valid_mode?(:neural_network)).to be false
98
+ end
99
+
100
+ it 'returns false for a string form of a valid mode' do
101
+ expect(described_class.valid_mode?('fault_localization')).to be false
102
+ end
103
+
104
+ it 'returns false for nil' do
105
+ expect(described_class.valid_mode?(nil)).to be false
106
+ end
107
+
108
+ it 'returns false for an integer' do
109
+ expect(described_class.valid_mode?(42)).to be false
110
+ end
111
+
112
+ it 'returns true for all REASONING_MODES members' do
113
+ described_class::REASONING_MODES.each do |mode|
114
+ expect(described_class.valid_mode?(mode)).to be true
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,262 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ RSpec.describe Legion::Extensions::Prediction::Helpers::PredictionStore do
6
+ subject(:store) { described_class.new }
7
+
8
+ let(:basic_prediction) do
9
+ {
10
+ mode: :fault_localization,
11
+ confidence: 0.75,
12
+ description: 'disk latency spike incoming',
13
+ status: :pending
14
+ }
15
+ end
16
+
17
+ describe '#initialize' do
18
+ it 'starts with empty predictions hash' do
19
+ expect(store.predictions).to eq({})
20
+ end
21
+
22
+ it 'starts with empty outcomes array' do
23
+ expect(store.outcomes).to eq([])
24
+ end
25
+ end
26
+
27
+ describe '#store' do
28
+ it 'returns a UUID string' do
29
+ id = store.store(basic_prediction.dup)
30
+ expect(id).to match(/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/)
31
+ end
32
+
33
+ it 'stores the prediction under the returned id' do
34
+ id = store.store(basic_prediction.dup)
35
+ expect(store.predictions[id]).not_to be_nil
36
+ end
37
+
38
+ it 'sets prediction_id on the prediction hash' do
39
+ prediction = basic_prediction.dup
40
+ id = store.store(prediction)
41
+ expect(prediction[:prediction_id]).to eq(id)
42
+ end
43
+
44
+ it 'preserves a caller-supplied prediction_id' do
45
+ prediction = basic_prediction.merge(prediction_id: 'my-custom-id')
46
+ returned_id = store.store(prediction)
47
+ expect(returned_id).to eq('my-custom-id')
48
+ expect(store.predictions['my-custom-id']).not_to be_nil
49
+ end
50
+
51
+ it 'sets created_at when not supplied' do
52
+ prediction = basic_prediction.dup
53
+ before = Time.now.utc
54
+ store.store(prediction)
55
+ expect(prediction[:created_at]).to be >= before
56
+ end
57
+
58
+ it 'preserves a caller-supplied created_at' do
59
+ custom_time = Time.now.utc - 3600
60
+ prediction = basic_prediction.merge(created_at: custom_time)
61
+ store.store(prediction)
62
+ expect(prediction[:created_at]).to eq(custom_time)
63
+ end
64
+
65
+ it 'sets default status to :pending' do
66
+ prediction = { mode: :counterfactual, confidence: 0.5 }
67
+ store.store(prediction)
68
+ expect(prediction[:status]).to eq(:pending)
69
+ end
70
+
71
+ it 'preserves a caller-supplied status' do
72
+ prediction = basic_prediction.merge(status: :expired)
73
+ store.store(prediction)
74
+ expect(prediction[:status]).to eq(:expired)
75
+ end
76
+
77
+ it 'increments count with each stored prediction' do
78
+ 3.times { store.store(basic_prediction.dup) }
79
+ expect(store.count).to eq(3)
80
+ end
81
+ end
82
+
83
+ describe '#get' do
84
+ it 'retrieves a stored prediction by id' do
85
+ id = store.store(basic_prediction.dup)
86
+ result = store.get(id)
87
+ expect(result[:mode]).to eq(:fault_localization)
88
+ end
89
+
90
+ it 'returns nil for an unknown id' do
91
+ expect(store.get('no-such-id')).to be_nil
92
+ end
93
+ end
94
+
95
+ describe '#resolve' do
96
+ let!(:prediction_id) { store.store(basic_prediction.dup) }
97
+
98
+ it 'returns the updated prediction' do
99
+ result = store.resolve(prediction_id, outcome: :correct)
100
+ expect(result).to be_a(Hash)
101
+ expect(result[:prediction_id]).to eq(prediction_id)
102
+ end
103
+
104
+ it 'sets status to the given outcome' do
105
+ store.resolve(prediction_id, outcome: :incorrect)
106
+ expect(store.get(prediction_id)[:status]).to eq(:incorrect)
107
+ end
108
+
109
+ it 'sets resolved_at timestamp' do
110
+ before = Time.now.utc
111
+ store.resolve(prediction_id, outcome: :correct)
112
+ expect(store.get(prediction_id)[:resolved_at]).to be >= before
113
+ end
114
+
115
+ it 'stores actual value when provided' do
116
+ store.resolve(prediction_id, outcome: :partial, actual: 'disk io at 80%')
117
+ expect(store.get(prediction_id)[:actual]).to eq('disk io at 80%')
118
+ end
119
+
120
+ it 'stores nil actual when not provided' do
121
+ store.resolve(prediction_id, outcome: :correct)
122
+ expect(store.get(prediction_id)[:actual]).to be_nil
123
+ end
124
+
125
+ it 'appends to outcomes array' do
126
+ store.resolve(prediction_id, outcome: :correct)
127
+ expect(store.outcomes.size).to eq(1)
128
+ end
129
+
130
+ it 'records prediction_id in the outcome entry' do
131
+ store.resolve(prediction_id, outcome: :correct)
132
+ expect(store.outcomes.last[:prediction_id]).to eq(prediction_id)
133
+ end
134
+
135
+ it 'records outcome in the outcome entry' do
136
+ store.resolve(prediction_id, outcome: :incorrect)
137
+ expect(store.outcomes.last[:outcome]).to eq(:incorrect)
138
+ end
139
+
140
+ it 'returns nil for a non-existent prediction_id' do
141
+ result = store.resolve('ghost-id', outcome: :correct)
142
+ expect(result).to be_nil
143
+ end
144
+
145
+ it 'caps outcomes array at 500 entries' do
146
+ 501.times do
147
+ id = store.store(basic_prediction.dup)
148
+ store.resolve(id, outcome: :correct)
149
+ end
150
+ expect(store.outcomes.size).to eq(500)
151
+ end
152
+ end
153
+
154
+ describe '#pending' do
155
+ it 'returns only predictions with status :pending' do
156
+ id1 = store.store(basic_prediction.dup)
157
+ id2 = store.store(basic_prediction.dup)
158
+ store.resolve(id1, outcome: :correct)
159
+
160
+ result = store.pending
161
+ expect(result.size).to eq(1)
162
+ expect(result.first[:prediction_id]).to eq(id2)
163
+ end
164
+
165
+ it 'returns empty array when no pending predictions' do
166
+ id = store.store(basic_prediction.dup)
167
+ store.resolve(id, outcome: :correct)
168
+ expect(store.pending).to be_empty
169
+ end
170
+
171
+ it 'returns all stored predictions when none are resolved' do
172
+ 3.times { store.store(basic_prediction.dup) }
173
+ expect(store.pending.size).to eq(3)
174
+ end
175
+ end
176
+
177
+ describe '#accuracy' do
178
+ it 'returns 0.0 when no outcomes exist' do
179
+ expect(store.accuracy).to eq(0.0)
180
+ end
181
+
182
+ it 'returns 1.0 when all outcomes are :correct' do
183
+ 3.times do
184
+ id = store.store(basic_prediction.dup)
185
+ store.resolve(id, outcome: :correct)
186
+ end
187
+ expect(store.accuracy).to eq(1.0)
188
+ end
189
+
190
+ it 'returns 0.0 when all outcomes are :incorrect' do
191
+ 3.times do
192
+ id = store.store(basic_prediction.dup)
193
+ store.resolve(id, outcome: :incorrect)
194
+ end
195
+ expect(store.accuracy).to eq(0.0)
196
+ end
197
+
198
+ it 'computes fractional accuracy correctly' do
199
+ 3.times do
200
+ id = store.store(basic_prediction.dup)
201
+ store.resolve(id, outcome: :correct)
202
+ end
203
+ id = store.store(basic_prediction.dup)
204
+ store.resolve(id, outcome: :incorrect)
205
+ expect(store.accuracy).to eq(0.75)
206
+ end
207
+
208
+ it 'defaults window to 100' do
209
+ 102.times do
210
+ id = store.store(basic_prediction.dup)
211
+ store.resolve(id, outcome: :incorrect)
212
+ end
213
+ 2.times do
214
+ id = store.store(basic_prediction.dup)
215
+ store.resolve(id, outcome: :correct)
216
+ end
217
+ # window=100: last 100 contain 2 correct out of 100 = 0.02
218
+ expect(store.accuracy(window: 100)).to eq(0.02)
219
+ end
220
+
221
+ it 'accepts a custom window parameter' do
222
+ 5.times do
223
+ id = store.store(basic_prediction.dup)
224
+ store.resolve(id, outcome: :incorrect)
225
+ end
226
+ 5.times do
227
+ id = store.store(basic_prediction.dup)
228
+ store.resolve(id, outcome: :correct)
229
+ end
230
+ # window=5: last 5 are all :correct
231
+ expect(store.accuracy(window: 5)).to eq(1.0)
232
+ end
233
+
234
+ it 'ignores non-:correct outcomes in numerator' do
235
+ id = store.store(basic_prediction.dup)
236
+ store.resolve(id, outcome: :partial)
237
+ id = store.store(basic_prediction.dup)
238
+ store.resolve(id, outcome: :expired)
239
+ id = store.store(basic_prediction.dup)
240
+ store.resolve(id, outcome: :correct)
241
+ expect(store.accuracy).to be_within(0.001).of(1.0 / 3.0)
242
+ end
243
+ end
244
+
245
+ describe '#count' do
246
+ it 'returns 0 for a new store' do
247
+ expect(store.count).to eq(0)
248
+ end
249
+
250
+ it 'returns the total number of stored predictions' do
251
+ 5.times { store.store(basic_prediction.dup) }
252
+ expect(store.count).to eq(5)
253
+ end
254
+
255
+ it 'counts resolved predictions as well as pending ones' do
256
+ id = store.store(basic_prediction.dup)
257
+ store.resolve(id, outcome: :correct)
258
+ store.store(basic_prediction.dup)
259
+ expect(store.count).to eq(2)
260
+ end
261
+ end
262
+ end
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/prediction/client'
4
+
5
+ RSpec.describe Legion::Extensions::Prediction::Runners::Prediction do
6
+ let(:client) { Legion::Extensions::Prediction::Client.new }
7
+
8
+ describe '#predict' do
9
+ it 'creates a prediction with valid mode' do
10
+ result = client.predict(mode: :fault_localization, description: 'test')
11
+ expect(result[:prediction_id]).to match(/\A[0-9a-f-]{36}\z/)
12
+ expect(result[:mode]).to eq(:fault_localization)
13
+ end
14
+
15
+ it 'rejects invalid mode' do
16
+ result = client.predict(mode: :invalid)
17
+ expect(result[:error]).to eq(:invalid_mode)
18
+ end
19
+
20
+ it 'marks actionable predictions above confidence threshold' do
21
+ result = client.predict(mode: :fault_localization, confidence: 0.9)
22
+ expect(result[:actionable]).to be true
23
+ end
24
+
25
+ it 'marks non-actionable predictions below threshold' do
26
+ result = client.predict(mode: :counterfactual, confidence: 0.3)
27
+ expect(result[:actionable]).to be false
28
+ end
29
+
30
+ it 'estimates confidence based on mode' do
31
+ fault = client.predict(mode: :fault_localization)
32
+ counterfactual = client.predict(mode: :counterfactual)
33
+ expect(fault[:confidence]).to be > counterfactual[:confidence]
34
+ end
35
+ end
36
+
37
+ describe '#resolve_prediction' do
38
+ it 'resolves a pending prediction' do
39
+ pred = client.predict(mode: :functional_mapping)
40
+ result = client.resolve_prediction(prediction_id: pred[:prediction_id], outcome: :correct)
41
+ expect(result[:resolved]).to be true
42
+ end
43
+
44
+ it 'returns not_found for missing prediction' do
45
+ result = client.resolve_prediction(prediction_id: 'nonexistent', outcome: :correct)
46
+ expect(result[:resolved]).to be false
47
+ end
48
+ end
49
+
50
+ describe '#pending_predictions' do
51
+ it 'lists pending predictions' do
52
+ client.predict(mode: :fault_localization)
53
+ client.predict(mode: :boundary_testing)
54
+ result = client.pending_predictions
55
+ expect(result[:count]).to eq(2)
56
+ end
57
+ end
58
+
59
+ describe '#expire_stale_predictions' do
60
+ it 'returns zero expired when no pending predictions' do
61
+ result = client.expire_stale_predictions
62
+ expect(result[:expired_count]).to eq(0)
63
+ expect(result[:remaining_pending]).to eq(0)
64
+ end
65
+
66
+ it 'expires predictions older than their horizon' do
67
+ client.predict(mode: :fault_localization)
68
+ client.predict(mode: :boundary_testing)
69
+
70
+ # Simulate staleness by back-dating created_at beyond the horizon
71
+ store = client.send(:prediction_store)
72
+ store.predictions.each_value do |pred|
73
+ pred[:created_at] = Time.now.utc - pred[:horizon] - 1
74
+ end
75
+
76
+ result = client.expire_stale_predictions
77
+ expect(result[:expired_count]).to eq(2)
78
+ expect(result[:remaining_pending]).to eq(0)
79
+ end
80
+
81
+ it 'preserves predictions that have not exceeded their horizon' do
82
+ client.predict(mode: :fault_localization)
83
+ # created_at is just now, horizon is 3600s — not stale
84
+ result = client.expire_stale_predictions
85
+ expect(result[:expired_count]).to eq(0)
86
+ expect(result[:remaining_pending]).to eq(1)
87
+ end
88
+
89
+ it 'only expires stale predictions when mixed with fresh ones' do
90
+ client.predict(mode: :fault_localization)
91
+ stale = client.predict(mode: :boundary_testing)
92
+
93
+ store = client.send(:prediction_store)
94
+ stale_pred = store.predictions[stale[:prediction_id]]
95
+ stale_pred[:created_at] = Time.now.utc - stale_pred[:horizon] - 1
96
+
97
+ result = client.expire_stale_predictions
98
+ expect(result[:expired_count]).to eq(1)
99
+ expect(result[:remaining_pending]).to eq(1)
100
+ end
101
+ end
102
+
103
+ describe '#prediction_accuracy' do
104
+ it 'computes accuracy' do
105
+ 3.times do
106
+ pred = client.predict(mode: :fault_localization)
107
+ client.resolve_prediction(prediction_id: pred[:prediction_id], outcome: :correct)
108
+ end
109
+ pred = client.predict(mode: :fault_localization)
110
+ client.resolve_prediction(prediction_id: pred[:prediction_id], outcome: :incorrect)
111
+
112
+ result = client.prediction_accuracy
113
+ expect(result[:accuracy]).to eq(0.75)
114
+ end
115
+ end
116
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'bundler/setup'
4
+
5
+ module Legion
6
+ module Logging
7
+ def self.debug(_msg); end
8
+ def self.info(_msg); end
9
+ def self.warn(_msg); end
10
+ def self.error(_msg); end
11
+ end
12
+ end
13
+
14
+ require 'legion/extensions/prediction'
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-prediction
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.1
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: Forward-model prediction engine (4 reasoning modes) for brain-modeled
27
+ agentic AI
28
+ email:
29
+ - matthewdiverson@gmail.com
30
+ executables: []
31
+ extensions: []
32
+ extra_rdoc_files: []
33
+ files:
34
+ - Gemfile
35
+ - lex-prediction.gemspec
36
+ - lib/legion/extensions/prediction.rb
37
+ - lib/legion/extensions/prediction/actors/expire_predictions.rb
38
+ - lib/legion/extensions/prediction/client.rb
39
+ - lib/legion/extensions/prediction/helpers/modes.rb
40
+ - lib/legion/extensions/prediction/helpers/prediction_store.rb
41
+ - lib/legion/extensions/prediction/runners/prediction.rb
42
+ - lib/legion/extensions/prediction/version.rb
43
+ - spec/legion/extensions/prediction/actors/expire_predictions_spec.rb
44
+ - spec/legion/extensions/prediction/client_spec.rb
45
+ - spec/legion/extensions/prediction/helpers/modes_spec.rb
46
+ - spec/legion/extensions/prediction/helpers/prediction_store_spec.rb
47
+ - spec/legion/extensions/prediction/runners/prediction_spec.rb
48
+ - spec/spec_helper.rb
49
+ homepage: https://github.com/LegionIO/lex-prediction
50
+ licenses:
51
+ - MIT
52
+ metadata:
53
+ homepage_uri: https://github.com/LegionIO/lex-prediction
54
+ source_code_uri: https://github.com/LegionIO/lex-prediction
55
+ documentation_uri: https://github.com/LegionIO/lex-prediction
56
+ changelog_uri: https://github.com/LegionIO/lex-prediction
57
+ bug_tracker_uri: https://github.com/LegionIO/lex-prediction/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 Prediction
76
+ test_files: []