lex-agentic-inference 0.1.11 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b1f50bf66664bcdee5e444a4b0fcde83953fb040d387fdb4c334b00d9410b74d
4
- data.tar.gz: fb0bfb4cba7f06a6de06179f0dd879a17854bf5e532fe8273f6f058f1912c2be
3
+ metadata.gz: 82dc037dc5706c8daa4c1a9a3688a5384f9e4312dd8352dca6a0ff2a13892778
4
+ data.tar.gz: 689d77694b89d51bfac49e14a01ff7cc99d4e1c4e5235cfc5dbcd6fc6b6f3bb1
5
5
  SHA512:
6
- metadata.gz: df96d191230ff4dc7ba69b8c2effb445c26e6a8dafefaae46dd30bc1fcb365908fe800c527f54ecac786fd8c42b46f97790e23dcc110c769b84a7c2707b196c6
7
- data.tar.gz: cd098aea29e07d96434a2a45012f5affb91637c05ba551e8bcd29eb9aeb1d5d41a2f184e91d3ce7d426fe07501776dc4966bcbdfe918b6dd3b45adb2329dff70
6
+ metadata.gz: b6aa092e075d7ae2110b1225fe44aa778d647d1a89ccfb421837f1ce06f9ea39dd078dcc594232b6fad8c4246c6421002ccd59bab3882505f77b0c9310bd1e30
7
+ data.tar.gz: eaba3c1bad5020560999363354687dec884c975c8467f8df01cebf4c98fd5872b421ce858d1bb4b2be3a7c36766b7bf514217986695cffdde4cf37480a9907fe
data/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.2.0] - 2026-10-29
4
+ ### Changed
5
+ - Default prediction clients and runner instances now share process-local state within the same identity partition. Pass `prediction_stores: {}` to a client when independent state ownership is required.
6
+ ### Fixed
7
+ - Make prediction resolution single-use so duplicate or conflicting feedback cannot skew accuracy, overwrite accepted outcomes, or emit repeated outcome traces.
8
+ - Keep wrong-identity lookup, resolution, and erasure isolated. This release does not add restart persistence or infer missing caller identity.
9
+
3
10
  ## [0.1.11] - 2026-07-23
4
11
  ### Fixed
5
12
  - Move `prediction_store` accessor into `Runners::Prediction` module so it works when extended onto RunnerHost (fixes NoMethodError during GAIA heartbeat tick)
data/README.md CHANGED
@@ -5,7 +5,7 @@ Domain consolidation gem for reasoning, inference, and belief management. Bundle
5
5
  ## Overview
6
6
 
7
7
  **Gem**: `lex-agentic-inference`
8
- **Version**: 0.1.8
8
+ **Version**: 0.2.0
9
9
  **Namespace**: `Legion::Extensions::Agentic::Inference`
10
10
 
11
11
  ## Sub-Modules
@@ -62,6 +62,22 @@ Domain consolidation gem for reasoning, inference, and belief management. Bundle
62
62
  gem 'lex-agentic-inference'
63
63
  ```
64
64
 
65
+ ## Prediction state ownership
66
+
67
+ Default `Prediction::Client` objects and prediction runners share a process-local
68
+ store for each `identity:`. Use the same verified identity for prediction,
69
+ lookup, resolution, and erasure. Omitting identity selects the shared unscoped
70
+ partition; it is not a substitute for principal attribution.
71
+
72
+ For an independent standalone client, pass `prediction_stores: {}` to
73
+ `Legion::Extensions::Agentic::Inference::Prediction::Client.new`. That client's
74
+ stores are not visible to default runtime runners.
75
+
76
+ Only the first resolution of a pending prediction is accepted. Repeated or
77
+ conflicting feedback returns `resolved: false, reason: :already_resolved`;
78
+ unknown IDs in the selected partition return `reason: :not_found`. These
79
+ stores are not durable across process restarts.
80
+
65
81
  ## Development
66
82
 
67
83
  ```bash
@@ -64,7 +64,7 @@ module Legion
64
64
  subset.to_h { |b| [b.id, b.posterior / total] }
65
65
  end
66
66
 
67
- def information_gain(belief_id:, evidence_id:, likelihood:) # rubocop:disable Lint/UnusedMethodArgument
67
+ def information_gain(belief_id:, likelihood:)
68
68
  belief = @beliefs[belief_id]
69
69
  return 0.0 unless belief
70
70
 
@@ -134,8 +134,8 @@ module Legion
134
134
  prior_q = 1.0 - prior_p
135
135
  post_q = 1.0 - post_p
136
136
 
137
- term1 = post_p * Math.log2(post_p / prior_p)
138
- term2 = post_q * Math.log2(post_q / prior_q)
137
+ term1 = post_p * Math.log2(post_p / prior_p)
138
+ term2 = post_q * Math.log2(post_q / prior_q)
139
139
  term1 + term2
140
140
  end
141
141
  end
@@ -77,7 +77,7 @@ module Legion
77
77
 
78
78
  def information_gain(belief_id:, evidence_id:, likelihood:, **)
79
79
  clamped = likelihood.clamp(Helpers::Constants::LIKELIHOOD_FLOOR, Helpers::Constants::LIKELIHOOD_CEILING)
80
- gain = belief_network.information_gain(belief_id: belief_id, evidence_id: evidence_id, likelihood: clamped)
80
+ gain = belief_network.information_gain(belief_id: belief_id, likelihood: clamped)
81
81
  log.debug "[bayesian_belief] information_gain: id=#{belief_id[0..7]} likelihood=#{clamped.round(3)} gain=#{gain.round(4)}"
82
82
  { success: true, belief_id: belief_id, evidence_id: evidence_id, likelihood: clamped, information_gain: gain }
83
83
  end
@@ -46,7 +46,7 @@ module Legion
46
46
  @controllability = controllability
47
47
  @confidence = confidence.clamp(CONFIDENCE_FLOOR, CONFIDENCE_CEILING)
48
48
  @emotional_response = ATTRIBUTION_EMOTIONS[pattern]
49
- @created_at = Time.now.utc
49
+ @created_at = Time.now.utc
50
50
  end
51
51
 
52
52
  def pattern
@@ -17,7 +17,7 @@ module Legion
17
17
  @pole_ids = []
18
18
  @alignment = 0.0
19
19
  @flux_density = 0.0
20
- @created_at = Time.now.utc
20
+ @created_at = Time.now.utc
21
21
  end
22
22
 
23
23
  def add_pole(pole_id)
@@ -12,8 +12,8 @@ module Legion
12
12
  class Client
13
13
  include Runners::Prediction
14
14
 
15
- def initialize(**)
16
- @prediction_stores = {}
15
+ def initialize(prediction_stores: nil, **)
16
+ @prediction_stores = prediction_stores
17
17
  end
18
18
  end
19
19
  end
@@ -9,12 +9,20 @@ module Legion
9
9
  module Prediction
10
10
  module Helpers
11
11
  class PredictionStore
12
+ @stores = {} # rubocop:disable ThreadSafety/MutableClassInstanceVariable -- Registry access is protected by @stores_mutex.
13
+ @stores_mutex = Mutex.new
14
+
15
+ def self.for_identity(identity:)
16
+ @stores_mutex.synchronize { @stores[identity] ||= new(identity: identity) }
17
+ end
18
+
12
19
  attr_reader :predictions, :outcomes, :identity
13
20
 
14
21
  def initialize(identity: nil)
15
22
  @identity = identity
16
23
  @predictions = {}
17
24
  @outcomes = []
25
+ @mutex = Mutex.new
18
26
  end
19
27
 
20
28
  def store(prediction)
@@ -31,16 +39,18 @@ module Legion
31
39
  end
32
40
 
33
41
  def resolve(prediction_id, outcome:, actual: nil)
34
- pred = @predictions[prediction_id]
35
- return nil unless pred
42
+ @mutex.synchronize do
43
+ pred = @predictions[prediction_id]
44
+ return nil unless pred && pred[:status] == :pending
36
45
 
37
- pred[:status] = outcome # :correct, :incorrect, :partial, :expired
38
- pred[:resolved_at] = Time.now.utc
39
- pred[:actual] = actual
46
+ pred[:status] = outcome # :correct, :incorrect, :partial, :expired
47
+ pred[:resolved_at] = Time.now.utc
48
+ pred[:actual] = actual
40
49
 
41
- @outcomes << { prediction_id: prediction_id, outcome: outcome, at: Time.now.utc }
42
- @outcomes.shift while @outcomes.size > 500
43
- pred
50
+ @outcomes << { prediction_id: prediction_id, outcome: outcome, at: pred[:resolved_at] }
51
+ @outcomes.shift while @outcomes.size > 500
52
+ pred
53
+ end
44
54
  end
45
55
 
46
56
  def pending
@@ -53,8 +53,9 @@ module Legion
53
53
  record_outcome_trace(pred, outcome)
54
54
  { resolved: true, prediction_id: prediction_id, outcome: outcome }
55
55
  else
56
- log.debug "[prediction] resolve failed: #{prediction_id[0..7]} not found"
57
- { resolved: false, reason: :not_found }
56
+ reason = store.get(prediction_id) ? :already_resolved : :not_found
57
+ log.debug "[prediction] resolve failed: #{prediction_id[0..7]} #{reason}"
58
+ { resolved: false, reason: reason }
58
59
  end
59
60
  end
60
61
 
@@ -81,8 +82,7 @@ module Legion
81
82
  age = Time.now.utc - pred[:created_at]
82
83
  next unless age > pred[:horizon]
83
84
 
84
- store.resolve(pred[:prediction_id], outcome: :expired, actual: nil)
85
- expired_count += 1
85
+ expired_count += 1 if store.resolve(pred[:prediction_id], outcome: :expired, actual: nil)
86
86
  end
87
87
 
88
88
  remaining = store.pending.size
@@ -105,8 +105,9 @@ module Legion
105
105
  end
106
106
 
107
107
  def prediction_store(identity: nil)
108
- @prediction_stores ||= {}
109
- @prediction_stores[identity] ||= Helpers::PredictionStore.new(identity: identity)
108
+ return @prediction_stores[identity] ||= Helpers::PredictionStore.new(identity: identity) if @prediction_stores
109
+
110
+ Helpers::PredictionStore.for_identity(identity: identity)
110
111
  end
111
112
 
112
113
  def estimate_confidence(mode, context)
@@ -4,7 +4,7 @@ module Legion
4
4
  module Extensions
5
5
  module Agentic
6
6
  module Inference
7
- VERSION = '0.1.11'
7
+ VERSION = '0.2.0'
8
8
  end
9
9
  end
10
10
  end
@@ -111,19 +111,19 @@ RSpec.describe Legion::Extensions::Agentic::Inference::Bayesian::Helpers::Belief
111
111
  describe '#information_gain' do
112
112
  it 'returns a non-negative float' do
113
113
  belief = add
114
- gain = network.information_gain(belief_id: belief.id, evidence_id: 'ev-1', likelihood: 0.9)
114
+ gain = network.information_gain(belief_id: belief.id, likelihood: 0.9)
115
115
  expect(gain).to be >= 0.0
116
116
  end
117
117
 
118
118
  it 'returns 0.0 for unknown belief' do
119
- gain = network.information_gain(belief_id: 'nonexistent', evidence_id: 'ev-1', likelihood: 0.8)
119
+ gain = network.information_gain(belief_id: 'nonexistent', likelihood: 0.8)
120
120
  expect(gain).to eq(0.0)
121
121
  end
122
122
 
123
123
  it 'does not mutate the belief posterior' do
124
124
  belief = add
125
125
  before = belief.posterior
126
- network.information_gain(belief_id: belief.id, evidence_id: 'ev-1', likelihood: 0.9)
126
+ network.information_gain(belief_id: belief.id, likelihood: 0.9)
127
127
  expect(belief.posterior).to eq(before)
128
128
  end
129
129
 
@@ -131,8 +131,8 @@ RSpec.describe Legion::Extensions::Agentic::Inference::Bayesian::Helpers::Belief
131
131
  b1 = add(content: 'h1')
132
132
  b2 = add(content: 'h2')
133
133
 
134
- gain_strong = network.information_gain(belief_id: b1.id, evidence_id: 'ev', likelihood: 0.95)
135
- gain_weak = network.information_gain(belief_id: b2.id, evidence_id: 'ev', likelihood: 0.55)
134
+ gain_strong = network.information_gain(belief_id: b1.id, likelihood: 0.95)
135
+ gain_weak = network.information_gain(belief_id: b2.id, likelihood: 0.55)
136
136
  expect(gain_strong).to be > gain_weak
137
137
  end
138
138
  end
@@ -3,7 +3,7 @@
3
3
  require 'legion/extensions/agentic/inference/prediction/client'
4
4
 
5
5
  RSpec.describe Legion::Extensions::Agentic::Inference::Prediction::Runners::Prediction do
6
- let(:client) { Legion::Extensions::Agentic::Inference::Prediction::Client.new }
6
+ let(:client) { Legion::Extensions::Agentic::Inference::Prediction::Client.new(prediction_stores: {}) }
7
7
 
8
8
  describe 'per-identity isolation' do
9
9
  it 'predictions for identity X are not visible to identity Y' do
@@ -42,6 +42,38 @@ RSpec.describe Legion::Extensions::Agentic::Inference::Prediction::Runners::Pred
42
42
  end
43
43
  end
44
44
 
45
+ describe 'shared runtime state' do
46
+ let(:identity) { SecureRandom.uuid }
47
+ let(:other_identity) { SecureRandom.uuid }
48
+ let(:producer) { Legion::Extensions::Agentic::Inference::Prediction::Client.new }
49
+ let(:consumer) { Object.new.extend(described_class) }
50
+
51
+ after do
52
+ producer.erase_partner!(identity: identity)
53
+ producer.erase_partner!(identity: other_identity)
54
+ end
55
+
56
+ it 'resolves a client prediction through separate runner instances without crossing identities' do
57
+ prediction = producer.predict(mode: :functional_mapping, confidence: 0.9, identity: identity)
58
+ other = producer.predict(mode: :functional_mapping, identity: other_identity)
59
+
60
+ expect(consumer.pending_predictions(identity: identity)[:predictions].map { |entry| entry[:prediction_id] })
61
+ .to eq([prediction[:prediction_id]])
62
+ expect(consumer.resolve_prediction(prediction_id: prediction[:prediction_id], outcome: :incorrect,
63
+ identity: other_identity)).to include(resolved: false, reason: :not_found)
64
+
65
+ resolver = Object.new.extend(described_class)
66
+ resolver.resolve_prediction(prediction_id: prediction[:prediction_id], outcome: :correct, identity: identity)
67
+
68
+ expect(producer.prediction_accuracy(identity: identity)).to eq(accuracy: 1.0, total_outcomes: 1)
69
+ expect(producer.pending_predictions(identity: identity)[:predictions]).to be_empty
70
+ expect(consumer.get_prediction(prediction_id: other[:prediction_id], identity: other_identity)[:prediction])
71
+ .to include(status: :pending)
72
+ consumer.erase_partner!(identity: identity)
73
+ expect(producer.get_prediction(prediction_id: prediction[:prediction_id], identity: identity)[:found]).to be false
74
+ end
75
+ end
76
+
45
77
  describe '#erase_partner!' do
46
78
  it 'removes all predictions and outcomes for the identity' do
47
79
  3.times do
@@ -3,7 +3,7 @@
3
3
  require 'legion/extensions/agentic/inference/prediction/client'
4
4
 
5
5
  RSpec.describe Legion::Extensions::Agentic::Inference::Prediction::Runners::Prediction do
6
- let(:client) { Legion::Extensions::Agentic::Inference::Prediction::Client.new }
6
+ let(:client) { Legion::Extensions::Agentic::Inference::Prediction::Client.new(prediction_stores: {}) }
7
7
 
8
8
  describe '#predict' do
9
9
  it 'creates a prediction with valid mode' do
@@ -45,6 +45,22 @@ RSpec.describe Legion::Extensions::Agentic::Inference::Prediction::Runners::Pred
45
45
  result = client.resolve_prediction(prediction_id: 'nonexistent', outcome: :correct)
46
46
  expect(result[:resolved]).to be false
47
47
  end
48
+
49
+ it 'counts a prediction outcome once and preserves the first resolution' do
50
+ correct = client.predict(mode: :functional_mapping)
51
+ incorrect = client.predict(mode: :functional_mapping)
52
+ client.resolve_prediction(prediction_id: correct[:prediction_id], outcome: :correct, actual: 'observed')
53
+ client.resolve_prediction(prediction_id: incorrect[:prediction_id], outcome: :incorrect)
54
+
55
+ repeated = client.resolve_prediction(prediction_id: correct[:prediction_id], outcome: :correct)
56
+ conflicting = client.resolve_prediction(prediction_id: correct[:prediction_id], outcome: :incorrect)
57
+
58
+ expect(repeated).to include(resolved: false, reason: :already_resolved)
59
+ expect(conflicting).to include(resolved: false, reason: :already_resolved)
60
+ expect(client.prediction_accuracy).to eq(accuracy: 0.5, total_outcomes: 2)
61
+ expect(client.get_prediction(prediction_id: correct[:prediction_id])[:prediction])
62
+ .to include(status: :correct, actual: 'observed')
63
+ end
48
64
  end
49
65
 
50
66
  describe '#pending_predictions' do
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lex-agentic-inference
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.11
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity