lex-agentic-learning 0.1.12 → 0.1.13

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: 0b9c9e5ffb94a852f69565d33096e38bc7314620ce3eb227c38f062b8ea79a13
4
- data.tar.gz: a305ff18874b4ebac3878db8f18304f24c5a2f9575a5711d9bd73015b2dd6ee3
3
+ metadata.gz: cecc51a27c68a22d11bae4df4784f6b5eeba66d6621911809596a3f8bba5c03b
4
+ data.tar.gz: 4190bc8f28519fad9f5acb205c944c88a99f40459dfd1a23ff62a77c3ec39643
5
5
  SHA512:
6
- metadata.gz: 78c10230805690d2207721eb815a8bce9bcbbc2c1d4c571d5c417dbd5d1480de5ce059fae848a6415d2a04c0c5bfbf9427ea2942f3d7f70c4a1f54e86277d987
7
- data.tar.gz: 7fa6dce7d74b53af81283e29dc590697fd746321f031abe48d4b34f9d5c9c58c0f07eab6725e5b84dcec0abb94cbb15bcea7e529bc28ad960619f8490867709a
6
+ metadata.gz: c79885b2f17dc71be2c0e10f1eb31994e08e9d5b2a0b093fd3576f859a75c8d6c8c4970c2a066eddffac3617b2a0e04a9b9977e6c31a7ddb843a50f232de0c77
7
+ data.tar.gz: ef96ad14cd80eda16d36821f5a327bdd393fb8cf49ab307307edfd2c0e531ba3b481091998407b1e06c8abf9b5868e48d014b95defa8636f5bc35c95a5a86b73
data/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.1.13] - 2026-10-29
4
+ ### Fixed
5
+ - Accept framework keyword/module outcome dispatch and reject unknown statuses or missing task attribution without updating learning models.
6
+ - Deduplicate explicit transport message identities across concurrent listener clients using bounded process-local history and a stable, eagerly initialized lock shared with state resets; repeated task IDs remain distinct executions.
7
+ - Report model update failures and identify task lessons as technical execution evidence, not demonstrated human acceptance.
8
+ - Fail closed when Apollo publication settings cannot be read. Event subscription activation and durable deduplication are unchanged.
9
+
3
10
  ## [0.1.12] - 2026-05-15
4
11
  ### Fixed
5
12
  - Curiosity `store_insight_in_apollo` now calls `Legion::Apollo.ingest` directly (replacing `Legion::Extensions::Apollo.store` which was a non-existent method and silently failed on every call); adds `access_scope: 'private'` and `identity_principal_id: nil`.
data/README.md CHANGED
@@ -5,7 +5,7 @@ Domain consolidation gem for learning, adaptation, and knowledge acquisition. Bu
5
5
  ## Overview
6
6
 
7
7
  **Gem**: `lex-agentic-learning`
8
- **Version**: 0.1.10
8
+ **Version**: 0.1.13
9
9
  **Namespace**: `Legion::Extensions::Agentic::Learning`
10
10
 
11
11
  ## Sub-Modules
@@ -14,7 +14,7 @@ module Legion
14
14
  end
15
15
 
16
16
  begin
17
- include Legion::Extensions::Helpers::Lex # rubocop:disable Layout/EmptyLinesAfterModuleInclusion
17
+ include Legion::Extensions::Helpers::Lex
18
18
  rescue StandardError => e
19
19
  log.error "[cognitive_chrysalis] failed to include Helpers::Lex: #{e.class}: #{e.message}"
20
20
  end
@@ -1,5 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'legion/extensions/agentic/learning/meta_learning/client'
4
+ require 'legion/extensions/agentic/learning/scaffolding/client'
5
+ require 'legion/extensions/agentic/learning/learning_rate/client'
3
6
  require 'legion/extensions/agentic/learning/outcome_listener/helpers/constants'
4
7
  require 'legion/extensions/agentic/learning/outcome_listener/helpers/domain_extractor'
5
8
  require 'legion/extensions/agentic/learning/outcome_listener/helpers/lesson_builder'
@@ -13,32 +16,42 @@ module Legion
13
16
  class Client
14
17
  include Runners::OutcomeListener
15
18
 
16
- # rubocop:disable ThreadSafety/ClassInstanceVariable
19
+ @outcome_mutex = Mutex.new
20
+
21
+ # rubocop:disable-next ThreadSafety/ClassInstanceVariable
17
22
  class << self
23
+ attr_reader :outcome_mutex
24
+
18
25
  def meta_client
19
- @meta_client ||= MetaLearning::Client.new
26
+ @meta_client ||= Legion::Extensions::Agentic::Learning::MetaLearning::Client.new
20
27
  end
21
28
 
22
29
  def scaffolding_client
23
- @scaffolding_client ||= Scaffolding::Client.new
30
+ @scaffolding_client ||= Legion::Extensions::Agentic::Learning::Scaffolding::Client.new
24
31
  end
25
32
 
26
33
  def learning_rate_client
27
- @learning_rate_client ||= LearningRate::Client.new
34
+ @learning_rate_client ||= Legion::Extensions::Agentic::Learning::LearningRate::Client.new
28
35
  end
29
36
 
30
37
  def domain_map
31
38
  @domain_map ||= {}
32
39
  end
33
40
 
41
+ def processed_outcomes
42
+ @processed_outcomes ||= {}
43
+ end
44
+
34
45
  def reset!
35
- @meta_client = nil
36
- @scaffolding_client = nil
37
- @learning_rate_client = nil
38
- @domain_map = nil
46
+ outcome_mutex.synchronize do
47
+ @meta_client = nil
48
+ @scaffolding_client = nil
49
+ @learning_rate_client = nil
50
+ @domain_map = nil
51
+ @processed_outcomes = nil
52
+ end
39
53
  end
40
54
  end
41
- # rubocop:enable ThreadSafety/ClassInstanceVariable
42
55
  end
43
56
  end
44
57
  end
@@ -10,6 +10,8 @@ module Legion
10
10
  DEFAULT_MIN_LESSON_SEVERITY = 0.3
11
11
  COMPLETED_STATUS = 'task.completed'
12
12
  FAILED_STATUS = 'task.failed'
13
+ EXCEPTION_STATUS = 'task.exception'
14
+ MAX_OUTCOME_HISTORY = 1000
13
15
  DEFAULT_DIFFICULTY = 0.5
14
16
  DEFAULT_CONFIDENCE_SUCCESS = 0.7
15
17
  DEFAULT_CONFIDENCE_FAILURE = 0.5
@@ -11,10 +11,11 @@ module Legion
11
11
 
12
12
  module_function
13
13
 
14
- def build(runner_class:, domain:, success:, status: nil, function: nil, source_agent: nil) # rubocop:disable Lint/UnusedMethodArgument
14
+ def build(runner_class:, domain:, success:, function: nil, source_agent: nil)
15
15
  {
16
16
  situation: build_situation(runner_class, function),
17
17
  outcome: success ? :success : :failure,
18
+ evidence: :technical_execution,
18
19
  lesson: build_lesson(domain, success),
19
20
  domain: domain,
20
21
  confidence: success ? DEFAULT_CONFIDENCE_SUCCESS : DEFAULT_CONFIDENCE_FAILURE,
@@ -31,9 +32,9 @@ module Legion
31
32
 
32
33
  def build_lesson(domain, success)
33
34
  if success
34
- "Task in domain '#{domain}' completed successfully"
35
+ "Task execution in domain '#{domain}' completed without an exception"
35
36
  else
36
- "Task in domain '#{domain}' failed — review for corrective action"
37
+ "Task execution in domain '#{domain}' failed — review for corrective action"
37
38
  end
38
39
  end
39
40
  end
@@ -10,7 +10,40 @@ module Legion
10
10
  include Legion::Extensions::Helpers::Lex if Legion::Extensions.const_defined?(:Helpers, false) &&
11
11
  Legion::Extensions::Helpers.const_defined?(:Lex, false)
12
12
 
13
- def process_outcome(payload = {}, **)
13
+ def process_outcome(payload = {}, **options)
14
+ payload = payload.merge(options)
15
+ status = payload[:status].to_s
16
+ unless [Helpers::Constants::COMPLETED_STATUS, Helpers::Constants::FAILED_STATUS,
17
+ Helpers::Constants::EXCEPTION_STATUS].include?(status)
18
+ return { success: false, skipped: true, reason: :unrecognized_status, outcome: nil }
19
+ end
20
+ if payload[:runner_class].to_s.strip.empty? || payload[:function].to_s.strip.empty?
21
+ return { success: false, skipped: true, reason: :missing_task_context, outcome: nil }
22
+ end
23
+
24
+ owner = Legion::Extensions::Agentic::Learning::OutcomeListener::Client
25
+ owner.outcome_mutex.synchronize do
26
+ process_unique_outcome(payload, owner)
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def process_unique_outcome(payload, owner)
33
+ message_id = payload[:message_id]
34
+ key = [payload[:source_agent] || payload[:agent_id], message_id]
35
+ tracked = !message_id.to_s.strip.empty?
36
+ return owner.processed_outcomes[key].merge(skipped: true, reason: :duplicate_outcome) if tracked && owner.processed_outcomes.key?(key)
37
+
38
+ result = process_task_outcome(payload)
39
+ if tracked
40
+ owner.processed_outcomes[key] = result
41
+ owner.processed_outcomes.shift while owner.processed_outcomes.size > Helpers::Constants::MAX_OUTCOME_HISTORY
42
+ end
43
+ result
44
+ end
45
+
46
+ def process_task_outcome(payload)
14
47
  runner_class_name = payload[:runner_class].to_s
15
48
  status = payload[:status].to_s
16
49
  domain = Helpers::DomainExtractor.extract(runner_class_name)
@@ -26,7 +59,6 @@ module Legion
26
59
  lesson = Helpers::LessonBuilder.build(
27
60
  runner_class: runner_class_name,
28
61
  function: payload[:function],
29
- status: status,
30
62
  domain: domain,
31
63
  success: success,
32
64
  source_agent: source_agent
@@ -36,13 +68,12 @@ module Legion
36
68
  end
37
69
 
38
70
  log.debug "[outcome_listener] domain=#{domain} success=#{success} updates=#{updates.keys}"
39
- { success: true, domain: domain, outcome: success, updates: updates }
71
+ { success: updates.values.none? { |update| update.key?(:error) },
72
+ domain: domain, outcome: success, evidence: :technical_execution, updates: updates }
40
73
  end
41
74
 
42
- private
43
-
44
75
  def update_meta_learning_model(domain, success)
45
- meta = self.class.meta_client
76
+ meta = Legion::Extensions::Agentic::Learning::OutcomeListener::Client.meta_client
46
77
  domain_id = resolve_domain_id(meta, domain)
47
78
  meta.record_learning_episode(domain_id: domain_id, success: success)
48
79
  rescue StandardError => e
@@ -51,7 +82,7 @@ module Legion
51
82
  end
52
83
 
53
84
  def update_learning_rate_model(domain, success)
54
- lr = self.class.learning_rate_client
85
+ lr = Legion::Extensions::Agentic::Learning::OutcomeListener::Client.learning_rate_client
55
86
  lr.record_prediction(correct: success, domain: domain.to_sym)
56
87
  rescue StandardError => e
57
88
  log.warn "[outcome_listener] learning_rate update failed: #{e.message}"
@@ -59,7 +90,7 @@ module Legion
59
90
  end
60
91
 
61
92
  def update_scaffolding_model(domain, success, payload)
62
- sc = self.class.scaffolding_client
93
+ sc = Legion::Extensions::Agentic::Learning::OutcomeListener::Client.scaffolding_client
63
94
  scaffolds = sc.engine.by_domain(domain: domain)
64
95
  return { skipped: true, reason: :no_scaffold } if scaffolds.empty?
65
96
 
@@ -72,7 +103,7 @@ module Legion
72
103
  end
73
104
 
74
105
  def resolve_domain_id(meta, domain)
75
- map = self.class.domain_map
106
+ map = Legion::Extensions::Agentic::Learning::OutcomeListener::Client.domain_map
76
107
  return map[domain] if map.key?(domain)
77
108
 
78
109
  result = meta.create_learning_domain(name: domain)
@@ -102,7 +133,7 @@ module Legion
102
133
  Legion::Settings.dig(:agentic, :learning, :write_to_apollo) != false
103
134
  rescue StandardError => e
104
135
  log.warn "[outcome_listener] write_to_apollo? check failed: #{e.message}"
105
- true
136
+ false
106
137
  end
107
138
 
108
139
  def min_lesson_severity
@@ -4,7 +4,7 @@ module Legion
4
4
  module Extensions
5
5
  module Agentic
6
6
  module Learning
7
- VERSION = '0.1.12'
7
+ VERSION = '0.1.13'
8
8
  end
9
9
  end
10
10
  end
@@ -9,7 +9,6 @@ RSpec.describe Legion::Extensions::Agentic::Learning::OutcomeListener::Helpers::
9
9
  described_class.build(
10
10
  runner_class: 'Legion::Extensions::Http::Runners::Get',
11
11
  function: 'fetch',
12
- status: 'task.completed',
13
12
  domain: 'http',
14
13
  success: true,
15
14
  source_agent: 'agent-1'
@@ -20,7 +19,6 @@ RSpec.describe Legion::Extensions::Agentic::Learning::OutcomeListener::Helpers::
20
19
  described_class.build(
21
20
  runner_class: 'Legion::Extensions::Consul::Runners::Kv',
22
21
  function: 'put',
23
- status: 'task.failed',
24
22
  domain: 'consul',
25
23
  success: false,
26
24
  source_agent: 'agent-2'
@@ -54,14 +52,6 @@ RSpec.describe Legion::Extensions::Agentic::Learning::OutcomeListener::Helpers::
54
52
  it 'includes recorded_at timestamp' do
55
53
  expect(success_lesson[:recorded_at]).to be_a(Time)
56
54
  end
57
-
58
- it 'builds lesson text for success' do
59
- expect(success_lesson[:lesson]).to include('completed successfully')
60
- end
61
-
62
- it 'builds lesson text for failure' do
63
- expect(failure_lesson[:lesson]).to include('failed')
64
- end
65
55
  end
66
56
 
67
57
  describe '.build_situation' do
@@ -109,10 +109,111 @@ RSpec.describe Legion::Extensions::Agentic::Learning::OutcomeListener::Runners::
109
109
  expect(episodes.proficiency).to be > 0.0
110
110
  end
111
111
 
112
- it 'handles empty payload gracefully' do
112
+ it 'ignores missing outcomes without creating learning state' do
113
113
  result = client.process_outcome({})
114
+ expect(result).to include(success: false, skipped: true, outcome: nil)
115
+ expect(Legion::Extensions::Agentic::Learning::OutcomeListener::Client.meta_client.meta_learning_stats[:episode_count]).to eq(0)
116
+ end
117
+
118
+ it 'consumes keyword-dispatched technical failures from the current runner' do
119
+ result = client.process_outcome(**failed_payload, status: 'task.exception')
120
+ expect(result).to include(success: true, outcome: false, evidence: :technical_execution)
121
+ expect(result[:updates][:meta_learning]).to include(domain_name: 'consul', success: false)
122
+ end
123
+
124
+ it 'ignores nonterminal and unrecognized statuses without changing existing learning' do
125
+ client.process_outcome(completed_payload)
126
+ owner = Legion::Extensions::Agentic::Learning::OutcomeListener::Client
127
+ before = owner.meta_client.learning_domain_ranking
128
+
129
+ [nil, 'task.queued', 'transformer.succeeded', 'unexpected'].each do |status|
130
+ result = client.process_outcome(**completed_payload, status: status)
131
+ expect(result).to include(success: false, skipped: true, reason: :unrecognized_status, outcome: nil)
132
+ end
133
+
134
+ expect(owner.meta_client.learning_domain_ranking).to eq(before)
135
+ expect(owner.meta_client.meta_learning_stats[:episode_count]).to eq(1)
136
+ end
137
+
138
+ it 'does not attribute context-free transport status updates to an unknown domain' do
139
+ result = client.process_outcome(task_id: 42, status: 'task.completed')
140
+ expect(result).to include(success: false, reason: :missing_task_context)
141
+ expect(Legion::Extensions::Agentic::Learning::OutcomeListener::Client.meta_client.meta_learning_stats[:domain_count]).to eq(0)
142
+ end
143
+
144
+ it 'does not reinforce a redelivered identified outcome twice across clients' do
145
+ payload = completed_payload.merge(task_id: 42, message_id: 'delivery-1')
146
+ client.process_outcome(**payload)
147
+ other = Legion::Extensions::Agentic::Learning::OutcomeListener::Client.new
148
+ result = other.process_outcome(**payload)
149
+ expect(result).to include(skipped: true, reason: :duplicate_outcome)
150
+ expect(Legion::Extensions::Agentic::Learning::OutcomeListener::Client.meta_client.meta_learning_stats[:episode_count]).to eq(1)
151
+ end
152
+
153
+ it 'reinforces concurrent redeliveries only once per shared state lifecycle' do
154
+ owner = Legion::Extensions::Agentic::Learning::OutcomeListener::Client
155
+ payload = completed_payload.merge(message_id: 'concurrent-delivery')
156
+
157
+ 2.times do
158
+ ready = Queue.new
159
+ start = Queue.new
160
+ workers = Array.new(8) do
161
+ Thread.new do
162
+ listener = owner.new
163
+ ready << true
164
+ start.pop
165
+ listener.process_outcome(**payload)
166
+ end
167
+ end
168
+ workers.size.times { ready.pop }
169
+ workers.size.times { start << true }
170
+ results = workers.map(&:value)
171
+
172
+ expect(results.count { |result| result[:reason] == :duplicate_outcome }).to eq(7)
173
+ expect(results).to all(include(success: true, outcome: true))
174
+ expect(owner.meta_client.meta_learning_stats[:episode_count]).to eq(1)
175
+ owner.reset!
176
+ end
177
+ end
178
+
179
+ it 'keeps different agents and a later terminal status distinct' do
180
+ payload = completed_payload.merge(task_id: 42, message_id: 'delivery-1')
181
+ client.process_outcome(**payload)
182
+ client.process_outcome(**payload, source_agent: 'agent-2')
183
+ client.process_outcome(**payload, status: 'task.exception', message_id: 'delivery-2')
184
+ owner = Legion::Extensions::Agentic::Learning::OutcomeListener::Client
185
+ domain = owner.meta_client.learning_domain_ranking[:ranking].first
186
+ expect(domain).to include(successes: 2, failures: 1)
187
+ end
188
+
189
+ it 'does not treat repeated task IDs as delivery identities' do
190
+ 2.times { client.process_outcome(**completed_payload, task_id: 42) }
191
+ owner = Legion::Extensions::Agentic::Learning::OutcomeListener::Client
192
+ expect(owner.meta_client.meta_learning_stats[:episode_count]).to eq(2)
193
+ end
194
+
195
+ it 'updates the same scaffold decision when invoked as a framework runner module' do
196
+ owner = Legion::Extensions::Agentic::Learning::OutcomeListener::Client
197
+ scaffold = owner.scaffolding_client.create_scaffold(skill_name: 'http_requests', domain: 'http')
198
+ id = scaffold[:scaffold][:id]
199
+ before = owner.scaffolding_client.recommend_scaffolded_task(scaffold_id: id)
200
+ runner = Module.new
201
+ runner.extend(described_class)
202
+ result = runner.process_outcome(**completed_payload)
203
+
114
204
  expect(result[:success]).to be true
115
- expect(result[:domain]).to eq('unknown')
205
+ after = owner.scaffolding_client.recommend_scaffolded_task(scaffold_id: id)
206
+ expect(after[:recommendation][:difficulty]).to be > before[:recommendation][:difficulty]
207
+ expect(owner.meta_client.meta_learning_stats[:episode_count]).to eq(1)
208
+ end
209
+
210
+ it 'fails closed when the lesson publication setting cannot be read' do
211
+ allow(Legion::Settings).to receive(:dig).with(:agentic, :learning, :write_to_apollo).and_raise(IOError, 'unavailable')
212
+ apollo = Module.new
213
+ stub_const('Legion::Apollo', apollo)
214
+ expect(apollo).not_to receive(:ingest)
215
+
216
+ expect(client.process_outcome(**completed_payload)[:success]).to be true
116
217
  end
117
218
  end
118
219
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lex-agentic-learning
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.12
4
+ version: 0.1.13
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity