lex-synapse 0.3.2 → 0.4.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: 4373abd3a8ec4e508cd8d276b77fc185b667c51e7a54f389313c8b2aa3f353c4
4
- data.tar.gz: 97df6f4f7fbef9db59155ef5aeea11636ad0c58fb82bc30a38c1f71207db39bf
3
+ metadata.gz: c62483ced1238be0da450c077108fa58541aa5964b27f3c6977ad11203202115
4
+ data.tar.gz: c8c8a52ff8eb60ebd971a4eeae4ae0a53709629548a58c4409bbdd7c5d3cfebe
5
5
  SHA512:
6
- metadata.gz: e36c0ef72aa48a0315c12868ec48602ca0b91adf46c842e75da5279799daaefb49b4c80bf2d28f6af8c4ef8b6db334d491e98bf33c9877f32809bafdf0b97c56
7
- data.tar.gz: 8570e52cee80e41d5dfc0a1c2de4a642210c471d8a4f59386e87d687815804fc22e7d73c8de1d43c31a58263876d8f44f3e3515c12e4861b6dfd1447d4414eec
6
+ metadata.gz: 50c4b74beca7a56c91953d34dd8179cd7f1c42af111ef05be4c9ec62f154135e93fac708c0270ed9c47ee73be987dd8386c5c47b638988982ff454980a4f2e0e
7
+ data.tar.gz: 3b6683e9c804a465f47d5fde8471768896ea7c4554e597a8a26c03313fb32e3d30c97cfa9e071726997455a3a5d4ea4392e1d9d94a20ce9ccc03c74de6318a2a
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.0] - 2026-03-22
4
+
5
+ ### Added
6
+ - Adversarial challenge phase for proposals: conflict detection, LLM challenge, weighted aggregation
7
+ - `synapse_challenges` table (migration 005) for per-challenge verdict tracking
8
+ - `Runners::Challenge` with challenge_proposal, resolve_challenge_outcomes, run_challenge_cycle
9
+ - `Helpers::Challenge` with settings, constants, impact threshold helpers
10
+ - `Actors::Challenge` polling every 60s for pending proposals
11
+ - Challenger confidence tracking with outcome-based learning loop
12
+ - Auto-accept/auto-reject thresholds for unanimous verdicts
13
+ - Client methods: challenge_proposal, challenges, challenger_stats
14
+ - New proposal statuses: auto_accepted, auto_rejected
15
+ - Impact scoring gates LLM challenge (expensive calls only for high-impact proposals)
16
+
3
17
  ## [0.3.2] - 2026-03-21
4
18
 
5
19
  ### Fixed
data/CLAUDE.md CHANGED
@@ -10,7 +10,7 @@ Cognitive routing layer that wraps task chain relationships with observation, le
10
10
 
11
11
  **GitHub**: https://github.com/LegionIO/lex-synapse
12
12
  **License**: MIT
13
- **Version**: 0.3.0
13
+ **Version**: 0.3.2
14
14
 
15
15
  ## Architecture
16
16
 
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Synapse
6
+ module Actor
7
+ class Challenge < Legion::Extensions::Actors::Every
8
+ def runner_function
9
+ 'run_challenge_cycle'
10
+ end
11
+
12
+ def time
13
+ 60
14
+ end
15
+
16
+ def use_runner?
17
+ false
18
+ end
19
+
20
+ def check_subtask?
21
+ false
22
+ end
23
+
24
+ def generate_task?
25
+ false
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -11,6 +11,7 @@ require_relative 'runners/dream'
11
11
  require_relative 'runners/promote'
12
12
  require_relative 'runners/retrieve'
13
13
  require_relative 'runners/propose'
14
+ require_relative 'runners/challenge'
14
15
  require_relative 'data/models/synapse_proposal'
15
16
  require_relative 'helpers/proposals'
16
17
 
@@ -29,6 +30,7 @@ module Legion
29
30
  include Runners::Promote
30
31
  include Runners::Retrieve
31
32
  include Runners::Propose
33
+ include Runners::Challenge
32
34
 
33
35
  attr_reader :conditioner_client, :transformer_client
34
36
 
@@ -79,6 +81,25 @@ module Legion
79
81
  proposal.update(status: status, reviewed_at: Time.now)
80
82
  { success: true, proposal_id: proposal_id, status: status }
81
83
  end
84
+
85
+ def challenge_proposal(proposal_id:)
86
+ super(proposal_id: proposal_id, transformer_client: @transformer_client)
87
+ end
88
+
89
+ def challenges(proposal_id:)
90
+ Data::Model.define_synapse_challenge_model
91
+ Data::Model::SynapseChallenge.where(proposal_id: proposal_id).order(Sequel.desc(:id)).all
92
+ end
93
+
94
+ def challenger_stats
95
+ Data::Model.define_synapse_challenge_model
96
+ resolved = Data::Model::SynapseChallenge.exclude(outcome: nil)
97
+ {
98
+ total: resolved.count,
99
+ correct: resolved.where(outcome: 'correct').count,
100
+ by_type: resolved.group_and_count(:challenger_type).to_h { |r| [r[:challenger_type], r[:count]] }
101
+ }
102
+ end
82
103
  end
83
104
  end
84
105
  end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ Sequel.migration do
4
+ up do
5
+ create_table(:synapse_challenges) do
6
+ primary_key :id
7
+ foreign_key :proposal_id, :synapse_proposals, null: false, index: true
8
+ String :challenger_type, null: false, size: 50
9
+ String :verdict, null: false, size: 50
10
+ String :reasoning, text: true
11
+ Float :challenger_confidence, default: 0.5
12
+ DateTime :created_at, null: false, default: Sequel::CURRENT_TIMESTAMP
13
+ DateTime :resolved_at
14
+ String :outcome, size: 50
15
+ end
16
+
17
+ alter_table(:synapse_proposals) do
18
+ add_column :challenge_state, String, size: 50
19
+ add_column :challenge_score, Float
20
+ add_column :impact_score, Float
21
+ end
22
+ end
23
+
24
+ down do
25
+ drop_table :synapse_challenges
26
+
27
+ alter_table(:synapse_proposals) do
28
+ drop_column :challenge_state
29
+ drop_column :challenge_score
30
+ drop_column :impact_score
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Synapse
6
+ module Data
7
+ module Model
8
+ def self.define_synapse_challenge_model
9
+ return if const_defined?(:SynapseChallenge, false)
10
+ return unless defined?(Legion::Data) && Legion::Settings.dig(:data, :connected)
11
+
12
+ db = Sequel::Model.db
13
+ return unless db&.table_exists?(:synapse_challenges)
14
+
15
+ klass = Class.new(Sequel::Model(:synapse_challenges)) do
16
+ many_to_one :proposal, class: 'Legion::Extensions::Synapse::Data::Model::SynapseProposal',
17
+ key: :proposal_id
18
+ end
19
+ klass.set_primary_key :id
20
+ const_set(:SynapseChallenge, klass)
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Synapse
6
+ module Helpers
7
+ module Challenge
8
+ VALID_VERDICTS = %w[support challenge abstain].freeze
9
+ VALID_CHALLENGER_TYPES = %w[conflict llm].freeze
10
+ VALID_OUTCOMES = %w[correct incorrect].freeze
11
+ VALID_CHALLENGE_STATES = %w[challenging challenged].freeze
12
+
13
+ IMPACT_WEIGHTS = {
14
+ 'llm_transform' => 0.7,
15
+ 'transform_mutation' => 0.5,
16
+ 'attention_mutation' => 0.6,
17
+ 'route_change' => 0.8
18
+ }.freeze
19
+
20
+ DEFAULT_SETTINGS = {
21
+ enabled: true,
22
+ impact_threshold: 0.3,
23
+ auto_accept_threshold: 0.85,
24
+ auto_reject_threshold: 0.15,
25
+ llm_engine_options: { temperature: 0.2, max_tokens: 512 },
26
+ outcome_observation_window: 50,
27
+ max_per_cycle: 5,
28
+ challenger_starting_confidence: 0.5,
29
+ challenger_correct_adjustment: 0.05,
30
+ challenger_incorrect_adjustment: -0.08
31
+ }.freeze
32
+
33
+ class << self
34
+ def settings
35
+ raw = Legion::Settings.dig('lex-synapse', 'challenge')
36
+ return DEFAULT_SETTINGS.dup unless raw.is_a?(Hash)
37
+
38
+ merged = DEFAULT_SETTINGS.dup
39
+ raw.each { |k, v| merged[k.to_sym] = v unless v.nil? }
40
+ merged
41
+ end
42
+
43
+ def enabled?
44
+ settings[:enabled] == true
45
+ end
46
+
47
+ def above_impact_threshold?(impact_score)
48
+ impact_score >= settings[:impact_threshold]
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -7,7 +7,7 @@ module Legion
7
7
  module Proposals
8
8
  VALID_PROPOSAL_TYPES = %w[llm_transform attention_mutation transform_mutation route_change].freeze
9
9
  VALID_TRIGGERS = %w[reactive proactive].freeze
10
- VALID_STATUSES = %w[pending approved rejected applied expired].freeze
10
+ VALID_STATUSES = %w[pending challenging challenged approved rejected applied expired auto_accepted auto_rejected].freeze
11
11
 
12
12
  DEFAULT_SETTINGS = {
13
13
  enabled: true,
@@ -0,0 +1,249 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../helpers/challenge'
4
+ require_relative '../helpers/confidence'
5
+ require_relative '../data/models/synapse'
6
+ require_relative '../data/models/synapse_proposal'
7
+ require_relative '../data/models/synapse_challenge'
8
+ require_relative '../data/models/synapse_signal'
9
+
10
+ module Legion
11
+ module Extensions
12
+ module Synapse
13
+ module Runners
14
+ module Challenge
15
+ def pending_challenges
16
+ Data::Model.define_synapse_proposal_model
17
+ Data::Model::SynapseProposal.where(status: 'pending', challenge_state: nil)
18
+ .order(Sequel.asc(:id)).all
19
+ end
20
+
21
+ def challenge_proposal(proposal_id:, transformer_client: nil)
22
+ Data::Model.define_synapse_proposal_model
23
+ Data::Model.define_synapse_challenge_model
24
+ Data::Model.define_synapse_model
25
+ Data::Model.define_synapse_signal_model
26
+
27
+ return { success: true, skipped: true } unless Helpers::Challenge.enabled?
28
+
29
+ proposal = Data::Model::SynapseProposal[proposal_id]
30
+ return { success: false, error: 'proposal not found' } unless proposal
31
+ return { success: false, error: 'proposal not challengeable' } unless proposal.status == 'pending' && proposal.challenge_state.nil?
32
+
33
+ synapse = Data::Model::Synapse[proposal.synapse_id]
34
+ return { success: false, error: 'synapse not found' } unless synapse
35
+
36
+ proposal.update(challenge_state: 'challenging')
37
+
38
+ impact = calculate_impact_score(proposal, synapse)
39
+ proposal.update(impact_score: impact)
40
+
41
+ conflict_check(proposal)
42
+
43
+ llm_challenge(proposal, synapse, transformer_client) if Helpers::Challenge.above_impact_threshold?(impact) && transformer_client
44
+
45
+ aggregate_challenges(proposal)
46
+ end
47
+
48
+ def resolve_challenge_outcomes(proposal_id:)
49
+ Data::Model.define_synapse_proposal_model
50
+ Data::Model.define_synapse_challenge_model
51
+ Data::Model.define_synapse_signal_model
52
+
53
+ proposal = Data::Model::SynapseProposal[proposal_id]
54
+ return { success: false, error: 'proposal not found' } unless proposal
55
+ return { success: false, error: 'proposal not applied' } unless proposal.status == 'applied'
56
+
57
+ settings = Helpers::Challenge.settings
58
+ window = settings[:outcome_observation_window] || 50
59
+
60
+ signals = Data::Model::SynapseSignal.where(synapse_id: proposal.synapse_id)
61
+ .order(Sequel.desc(:id)).limit(window).all
62
+ return { success: false, error: 'insufficient signals' } if signals.size < window
63
+
64
+ success_rate = signals.count(&:transform_success).to_f / signals.size
65
+ proposal_succeeded = success_rate >= 0.7
66
+
67
+ challenges = Data::Model::SynapseChallenge.where(proposal_id: proposal_id)
68
+ .exclude(verdict: 'abstain').all
69
+
70
+ challenges.each do |challenge|
71
+ supported = challenge.verdict == 'support'
72
+ correct = (supported && proposal_succeeded) || (!supported && !proposal_succeeded)
73
+ outcome = correct ? 'correct' : 'incorrect'
74
+
75
+ adj = correct ? settings[:challenger_correct_adjustment] : settings[:challenger_incorrect_adjustment]
76
+ new_conf = (challenge.challenger_confidence + adj).clamp(0.0, 1.0)
77
+
78
+ challenge.update(outcome: outcome, challenger_confidence: new_conf, resolved_at: Time.now)
79
+ end
80
+
81
+ { success: true, proposal_id: proposal_id, success_rate: success_rate, resolved: challenges.size }
82
+ end
83
+
84
+ def run_challenge_cycle(transformer_client: nil)
85
+ Data::Model.define_synapse_proposal_model
86
+ Data::Model.define_synapse_challenge_model
87
+ return { challenged: 0, resolved: 0 } unless Helpers::Challenge.enabled?
88
+
89
+ settings = Helpers::Challenge.settings
90
+ max = settings[:max_per_cycle] || 5
91
+
92
+ challenged = 0
93
+ pending_challenges.first(max).each do |proposal|
94
+ challenge_proposal(proposal_id: proposal.id, transformer_client: transformer_client)
95
+ challenged += 1
96
+ end
97
+
98
+ resolved = 0
99
+ window = settings[:outcome_observation_window] || 50
100
+ Data::Model.define_synapse_signal_model
101
+ Data::Model::SynapseProposal.where(status: 'applied').each do |proposal|
102
+ cutoff = proposal.respond_to?(:reviewed_at) && proposal.reviewed_at ? proposal.reviewed_at : proposal.created_at
103
+ post_signals = Data::Model::SynapseSignal.where(synapse_id: proposal.synapse_id)
104
+ .where { created_at >= cutoff }.count
105
+ next unless post_signals >= window
106
+
107
+ unresolved = Data::Model::SynapseChallenge.where(proposal_id: proposal.id, outcome: nil)
108
+ .exclude(verdict: 'abstain')
109
+ next unless unresolved.any?
110
+
111
+ resolve_challenge_outcomes(proposal_id: proposal.id)
112
+ resolved += 1
113
+ end
114
+
115
+ { challenged: challenged, resolved: resolved }
116
+ end
117
+
118
+ private
119
+
120
+ def conflict_check(proposal)
121
+ conflicts = Data::Model::SynapseProposal.where(
122
+ synapse_id: proposal.synapse_id,
123
+ proposal_type: proposal.proposal_type,
124
+ status: 'pending'
125
+ ).exclude(id: proposal.id)
126
+
127
+ if conflicts.any?
128
+ Data::Model::SynapseChallenge.create(
129
+ proposal_id: proposal.id, challenger_type: 'conflict',
130
+ verdict: 'challenge',
131
+ reasoning: "#{conflicts.count} conflicting #{proposal.proposal_type} proposal(s) pending on same synapse",
132
+ challenger_confidence: Helpers::Challenge.settings[:challenger_starting_confidence]
133
+ )
134
+ else
135
+ Data::Model::SynapseChallenge.create(
136
+ proposal_id: proposal.id, challenger_type: 'conflict',
137
+ verdict: 'support', reasoning: 'no conflicting proposals',
138
+ challenger_confidence: Helpers::Challenge.settings[:challenger_starting_confidence]
139
+ )
140
+ end
141
+ end
142
+
143
+ def llm_challenge(proposal, synapse, transformer_client)
144
+ prompt = build_challenge_prompt(proposal, synapse)
145
+ engine_options = Helpers::Challenge.settings[:llm_engine_options]
146
+
147
+ result = transformer_client.transform(
148
+ transformation: prompt, payload: {}, engine: :llm, engine_options: engine_options
149
+ )
150
+
151
+ verdict, reasoning = parse_llm_verdict(result[:success] ? result[:result] : nil)
152
+
153
+ llm_confidence = rolling_llm_confidence
154
+ Data::Model::SynapseChallenge.create(
155
+ proposal_id: proposal.id, challenger_type: 'llm',
156
+ verdict: verdict, reasoning: reasoning,
157
+ challenger_confidence: llm_confidence
158
+ )
159
+ rescue StandardError => e
160
+ Legion::Logging.warn("Challenge LLM call failed: #{e.message}") if defined?(Legion::Logging)
161
+ Data::Model::SynapseChallenge.create(
162
+ proposal_id: proposal.id, challenger_type: 'llm',
163
+ verdict: 'abstain', reasoning: "LLM error: #{e.message}",
164
+ challenger_confidence: Helpers::Challenge.settings[:challenger_starting_confidence]
165
+ )
166
+ end
167
+
168
+ def aggregate_challenges(proposal)
169
+ challenges = Data::Model::SynapseChallenge.where(proposal_id: proposal.id)
170
+ .exclude(verdict: 'abstain').all
171
+
172
+ if challenges.empty?
173
+ proposal.update(challenge_state: 'challenged', challenge_score: 0.5)
174
+ return { success: true, challenge_score: 0.5, decision: 'challenged' }
175
+ end
176
+
177
+ support_weight = challenges.select { |c| c.verdict == 'support' }.sum(&:challenger_confidence)
178
+ challenge_weight = challenges.select { |c| c.verdict == 'challenge' }.sum(&:challenger_confidence)
179
+ total = support_weight + challenge_weight
180
+
181
+ score = total.zero? ? 0.5 : support_weight / total
182
+
183
+ settings = Helpers::Challenge.settings
184
+ decision = if score >= settings[:auto_accept_threshold]
185
+ proposal.update(status: 'auto_accepted', challenge_state: 'challenged', challenge_score: score)
186
+ 'auto_accepted'
187
+ elsif score <= settings[:auto_reject_threshold]
188
+ proposal.update(status: 'auto_rejected', challenge_state: 'challenged', challenge_score: score)
189
+ 'auto_rejected'
190
+ else
191
+ proposal.update(challenge_state: 'challenged', challenge_score: score)
192
+ 'challenged'
193
+ end
194
+
195
+ { success: true, challenge_score: score, decision: decision }
196
+ end
197
+
198
+ def calculate_impact_score(proposal, synapse)
199
+ base = Helpers::Challenge::IMPACT_WEIGHTS.fetch(proposal.proposal_type, 0.5)
200
+ recent_signals = Data::Model::SynapseSignal.where(synapse_id: synapse.id).count
201
+ baseline = [synapse.respond_to?(:baseline_throughput) && synapse.baseline_throughput ? synapse.baseline_throughput : 1.0, 1.0].max
202
+ throughput_factor = [recent_signals.to_f / baseline, 2.0].min
203
+
204
+ (base * synapse.confidence * throughput_factor).clamp(0.0, 1.0)
205
+ end
206
+
207
+ def build_challenge_prompt(proposal, synapse)
208
+ "Evaluate this proposed change to a cognitive routing synapse.\n\n" \
209
+ "Synapse confidence: #{synapse.confidence}\n" \
210
+ "Proposal type: #{proposal.proposal_type}\n" \
211
+ "Rationale: #{proposal.rationale}\n" \
212
+ "Proposed inputs: #{proposal.inputs}\n" \
213
+ "Proposed output: #{proposal.output}\n\n" \
214
+ "Is this change sound? Respond in exactly this format:\n" \
215
+ "VERDICT: SUPPORT or CHALLENGE or ABSTAIN\n" \
216
+ 'REASONING: one sentence explanation'
217
+ end
218
+
219
+ def parse_llm_verdict(response)
220
+ return ['abstain', 'no LLM response'] unless response.is_a?(String)
221
+
222
+ text = response.to_s.strip
223
+ verdict = if text.match?(/VERDICT:\s*SUPPORT/i)
224
+ 'support'
225
+ elsif text.match?(/VERDICT:\s*CHALLENGE/i)
226
+ 'challenge'
227
+ else
228
+ 'abstain'
229
+ end
230
+
231
+ reasoning_match = text.match(/REASONING:\s*(.+)/i)
232
+ reasoning = reasoning_match ? reasoning_match[1].strip : text.slice(0, 200)
233
+
234
+ [verdict, reasoning]
235
+ end
236
+
237
+ def rolling_llm_confidence
238
+ recent = Data::Model::SynapseChallenge.where(challenger_type: 'llm')
239
+ .exclude(outcome: nil)
240
+ .order(Sequel.desc(:id)).limit(20).all
241
+ return Helpers::Challenge.settings[:challenger_starting_confidence] if recent.empty?
242
+
243
+ recent.first.challenger_confidence
244
+ end
245
+ end
246
+ end
247
+ end
248
+ end
249
+ end
@@ -3,7 +3,7 @@
3
3
  module Legion
4
4
  module Extensions
5
5
  module Synapse
6
- VERSION = '0.3.2'
6
+ VERSION = '0.4.0'
7
7
  end
8
8
  end
9
9
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lex-synapse
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.2
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -56,6 +56,7 @@ files:
56
56
  - Rakefile
57
57
  - lex-synapse.gemspec
58
58
  - lib/legion/extensions/synapse.rb
59
+ - lib/legion/extensions/synapse/actors/challenge.rb
59
60
  - lib/legion/extensions/synapse/actors/crystallize.rb
60
61
  - lib/legion/extensions/synapse/actors/decay.rb
61
62
  - lib/legion/extensions/synapse/actors/evaluate.rb
@@ -67,14 +68,18 @@ files:
67
68
  - lib/legion/extensions/synapse/data/migrations/002_create_synapse_mutations.rb
68
69
  - lib/legion/extensions/synapse/data/migrations/003_create_synapse_signals.rb
69
70
  - lib/legion/extensions/synapse/data/migrations/004_create_synapse_proposals.rb
71
+ - lib/legion/extensions/synapse/data/migrations/005_add_synapse_challenges.rb
70
72
  - lib/legion/extensions/synapse/data/models/synapse.rb
73
+ - lib/legion/extensions/synapse/data/models/synapse_challenge.rb
71
74
  - lib/legion/extensions/synapse/data/models/synapse_mutation.rb
72
75
  - lib/legion/extensions/synapse/data/models/synapse_proposal.rb
73
76
  - lib/legion/extensions/synapse/data/models/synapse_signal.rb
77
+ - lib/legion/extensions/synapse/helpers/challenge.rb
74
78
  - lib/legion/extensions/synapse/helpers/confidence.rb
75
79
  - lib/legion/extensions/synapse/helpers/homeostasis.rb
76
80
  - lib/legion/extensions/synapse/helpers/proposals.rb
77
81
  - lib/legion/extensions/synapse/helpers/relationship_wrapper.rb
82
+ - lib/legion/extensions/synapse/runners/challenge.rb
78
83
  - lib/legion/extensions/synapse/runners/crystallize.rb
79
84
  - lib/legion/extensions/synapse/runners/dream.rb
80
85
  - lib/legion/extensions/synapse/runners/evaluate.rb