lex-agentic-social 0.1.18 → 0.1.19

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: c669852940dd9abfa0890d908ed6520df5ba9aa89dad4fc58fadd28a8ecd8529
4
- data.tar.gz: 65832a10c828873e86131c0e1c7adbc23708a70f607ace40f73e1b4aa457dd33
3
+ metadata.gz: 49f5148e440e4e58060bf6ff47c9dc8d31bfde5092ab17b7ad078fb95344e711
4
+ data.tar.gz: 191fddf9d835866196a20ed7efafcbfb4ef34729d7d5b585f7d68a95dc225b00
5
5
  SHA512:
6
- metadata.gz: 821f62c6dd7086ab97cc23958319080e4b6fcd8eddb2f59929bc6c9c9fa24291ef3c2d32eedf08767f67a5cbbad312a9ea9e12703429060a9d3aa3a4d2327284
7
- data.tar.gz: 83751ac84ae50a28275479b1ef216f40ec43de7d75f45116e7e383ca649f73763da16b00ca9737ab76d67a23bcf9f3352841c8c49f806dc745874e3f334ab136
6
+ metadata.gz: e052b2d246149565565a6f0a7654bb7d7bf689bc149c2bb17b22fdcc59c15339178805127b1a07e8a602a3d9fd1fcebb3e5b3a1bacef01622f6f4ac934be120f
7
+ data.tar.gz: 92f66448450959900ac87054fc9a5b4e9e442b3217f52d35e7b8a6e17c377317932c6f92ed129d64c82c177b101d7c95d30ce0a7e52a818f4d74c49edc87a695
data/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.1.19] - 2026-06-01
4
+ ### Fixed
5
+ - Calibration LLM preference extraction now uses `Legion::LLM.chat` with explicit system/user messages instead of legacy `Legion::LLM.ask`.
6
+ - Governance Proposal store now persists to Apollo Local on create, vote, and resolution — safety-critical proposal state survives restarts.
7
+ - ConsentMap changes (tier changes, action outcomes, pending approvals) are now saved to local storage automatically on every mutation.
8
+ - ConflictLog now evicts old resolved conflicts (30-day retention) and enforces a 1000-entry cap to prevent unbounded memory growth.
9
+
3
10
  ## [0.1.18] - 2026-05-29
4
11
  ### Added
5
12
  - Database index on `trust_entries.domain` column for faster filtered domain lookups
@@ -65,7 +65,13 @@ module Legion
65
65
 
66
66
  context = summarize_traces(traces)
67
67
  prompt = build_preference_prompt(context)
68
- result = Legion::LLM.ask(message: prompt)
68
+ result = Legion::LLM.chat(
69
+ message: [
70
+ { role: 'system', content: 'You are a preference inference engine. Respond ONLY with JSON.' },
71
+ { role: 'user', content: prompt }
72
+ ],
73
+ caller: { extension: 'lex-agentic-social', mode: :calibration }
74
+ )
69
75
  return { success: false, error: :llm_failed } unless result&.content
70
76
 
71
77
  parsed = parse_preference_response(result.content)
@@ -9,6 +9,9 @@ module Legion
9
9
  module Conflict
10
10
  module Helpers
11
11
  class ConflictLog
12
+ MAX_CONFLICTS = 1000
13
+ RESOLVED_RETENTION_DAYS = 30
14
+
12
15
  attr_reader :conflicts
13
16
 
14
17
  def initialize
@@ -61,6 +64,39 @@ module Legion
61
64
  def count
62
65
  @conflicts.size
63
66
  end
67
+
68
+ def evict
69
+ evict_expired_resolved
70
+ evict_overflow
71
+ end
72
+
73
+ private
74
+
75
+ def evict_expired_resolved
76
+ cutoff = Time.now.utc - (RESOLVED_RETENTION_DAYS * 86_400)
77
+ @conflicts.reject! do |_id, conflict|
78
+ conflict[:status] == :resolved && conflict[:resolved_at] && conflict[:resolved_at] < cutoff
79
+ end
80
+ end
81
+
82
+ def evict_overflow
83
+ return if @conflicts.size <= MAX_CONFLICTS
84
+
85
+ # Evict oldest resolved conflicts first, then oldest active
86
+ resolved = @conflicts.select { |_, c| c[:status] == :resolved }
87
+ active = @conflicts.select { |_, c| c[:status] == :active }
88
+
89
+ # Remove oldest resolved until under limit
90
+ resolved.sort_by { |_, c| c[:resolved_at] || c[:created_at] }
91
+ .first(resolved.size - 50)
92
+ .each_key { |id| @conflicts.delete(id) }
93
+ return if @conflicts.size <= MAX_CONFLICTS
94
+
95
+ # Still over? Remove oldest active
96
+ active.sort_by { |_, c| c[:created_at] }
97
+ .first(active.size - 10)
98
+ .each_key { |id| @conflicts.delete(id) }
99
+ end
64
100
  end
65
101
  end
66
102
  end
@@ -17,6 +17,7 @@ module Legion
17
17
 
18
18
  id = conflict_log.record(parties: parties, severity: severity, description: description)
19
19
  conflict = conflict_log.get(id)
20
+ conflict_log.evict
20
21
  log.info "[conflict] registered: id=#{id[0..7]} severity=#{severity} posture=#{conflict[:posture]} parties=#{parties.join(',')}"
21
22
  { conflict_id: id, severity: severity, posture: conflict[:posture] }
22
23
  end
@@ -14,38 +14,32 @@ module Legion
14
14
  attr_reader :domains
15
15
 
16
16
  def initialize
17
- @domains = Hash.new do |h, k|
18
- h[k] = {
19
- tier: Tiers::DEFAULT_TIER,
20
- success_count: 0,
21
- failure_count: 0,
22
- total_actions: 0,
23
- last_changed_at: nil,
24
- history: [],
25
- pending_tier: nil,
26
- pending_since: nil,
27
- pending_requested_by: nil
28
- }
29
- end
17
+ @domains = {}
30
18
  load_from_local
31
19
  end
32
20
 
33
21
  def get_tier(domain)
34
- @domains[domain][:tier]
22
+ entry = @domains[domain]
23
+ return Tiers::DEFAULT_TIER unless entry
24
+
25
+ entry[:tier]
35
26
  end
36
27
 
37
28
  def set_tier(domain, tier)
38
29
  return unless Tiers.valid_tier?(tier)
39
30
 
31
+ ensure_domain(domain)
40
32
  entry = @domains[domain]
41
33
  old_tier = entry[:tier]
42
34
  entry[:tier] = tier
43
35
  entry[:last_changed_at] = Time.now.utc
44
36
  entry[:history] << { from: old_tier, to: tier, at: Time.now.utc }
45
37
  entry[:history].shift while entry[:history].size > 50
38
+ persist!
46
39
  end
47
40
 
48
41
  def record_outcome(domain, success:)
42
+ ensure_domain(domain)
49
43
  entry = @domains[domain]
50
44
  entry[:total_actions] += 1
51
45
  if success
@@ -53,18 +47,19 @@ module Legion
53
47
  else
54
48
  entry[:failure_count] += 1
55
49
  end
50
+ persist!
56
51
  end
57
52
 
58
53
  def success_rate(domain)
59
54
  entry = @domains[domain]
60
- return 0.0 if entry[:total_actions].zero?
55
+ return 0.0 unless entry && entry[:total_actions].positive?
61
56
 
62
57
  entry[:success_count].to_f / entry[:total_actions]
63
58
  end
64
59
 
65
60
  def eligible_for_change?(domain)
66
61
  entry = @domains[domain]
67
- return false if entry[:total_actions] < Tiers::MIN_ACTIONS_TO_PROMOTE
62
+ return false unless entry && entry[:total_actions] >= Tiers::MIN_ACTIONS_TO_PROMOTE
68
63
 
69
64
  if entry[:last_changed_at]
70
65
  (Time.now.utc - entry[:last_changed_at]) >= Tiers::PROMOTION_COOLDOWN
@@ -106,28 +101,36 @@ module Legion
106
101
  end
107
102
 
108
103
  def set_pending(domain, proposed_tier:, requested_by: 'system')
104
+ ensure_domain(domain)
109
105
  entry = @domains[domain]
110
106
  entry[:pending_tier] = proposed_tier
111
107
  entry[:pending_since] = Time.now
112
108
  entry[:pending_requested_by] = requested_by
109
+ persist!
113
110
  entry
114
111
  end
115
112
 
116
113
  def clear_pending(domain)
117
114
  entry = @domains[domain]
115
+ return unless entry
116
+
118
117
  entry[:pending_tier] = nil
119
118
  entry[:pending_since] = nil
120
119
  entry[:pending_requested_by] = nil
120
+ persist!
121
121
  entry
122
122
  end
123
123
 
124
124
  def pending?(domain)
125
- !@domains[domain][:pending_tier].nil?
125
+ entry = @domains[domain]
126
+ return false unless entry
127
+
128
+ !entry[:pending_tier].nil?
126
129
  end
127
130
 
128
131
  def pending_expired?(domain, timeout: APPROVAL_TIMEOUT)
129
132
  entry = @domains[domain]
130
- return false unless entry[:pending_since]
133
+ return false unless entry && entry[:pending_since]
131
134
 
132
135
  Time.now - entry[:pending_since] > timeout
133
136
  end
@@ -170,6 +173,7 @@ module Legion
170
173
  []
171
174
  end
172
175
 
176
+ @domains[key] = new_entry
173
177
  @domains[key].merge!(
174
178
  tier: row[:tier].to_sym,
175
179
  success_count: row[:success_count].to_i,
@@ -185,6 +189,28 @@ module Legion
185
189
 
186
190
  private
187
191
 
192
+ def ensure_domain(domain)
193
+ @domains[domain] = new_entry unless @domains.key?(domain)
194
+ end
195
+
196
+ def new_entry
197
+ {
198
+ tier: Tiers::DEFAULT_TIER,
199
+ success_count: 0,
200
+ failure_count: 0,
201
+ total_actions: 0,
202
+ last_changed_at: nil,
203
+ history: [],
204
+ pending_tier: nil,
205
+ pending_since: nil,
206
+ pending_requested_by: nil
207
+ }
208
+ end
209
+
210
+ def persist!
211
+ save_to_local
212
+ end
213
+
188
214
  def success_rate_from(entry)
189
215
  return 0.0 if entry[:total_actions].zero?
190
216
 
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'time'
3
4
  require 'securerandom'
4
5
 
5
6
  module Legion
@@ -8,16 +9,14 @@ module Legion
8
9
  module Social
9
10
  module Governance
10
11
  module Helpers
11
- # NOTE: Proposal state is stored in-memory only (@proposals hash).
12
- # This is a safety-critical system (controls consent and containment approval).
13
- # Persistence to a durable store (e.g. legion-data) is required so that
14
- # proposals survive process restarts. Implementing persistence is tracked
15
- # as a separate task.
16
12
  class Proposal
13
+ PROPOSAL_TAG_BASE = %w[governance proposal].freeze
14
+
17
15
  attr_reader :proposals
18
16
 
19
17
  def initialize
20
18
  @proposals = {}
19
+ load_from_apollo
21
20
  end
22
21
 
23
22
  def create(category:, description:, proposer:, council_size: Layers::MIN_COUNCIL_SIZE)
@@ -34,6 +33,7 @@ module Legion
34
33
  created_at: Time.now.utc,
35
34
  resolved_at: nil
36
35
  }
36
+ save_proposal(id)
37
37
  id
38
38
  end
39
39
 
@@ -51,6 +51,7 @@ module Legion
51
51
  prop[:votes_against] << voter
52
52
  end
53
53
 
54
+ save_proposal(proposal_id)
54
55
  check_resolution(proposal_id)
55
56
  end
56
57
 
@@ -68,11 +69,65 @@ module Legion
68
69
 
69
70
  prop[:status] = :timed_out
70
71
  prop[:resolved_at] = Time.now.utc
72
+ save_proposal(proposal_id)
71
73
  prop
72
74
  end
73
75
 
76
+ def load_from_apollo
77
+ return false unless defined?(Legion::Apollo::Local) && Legion::Apollo::Local.started?
78
+
79
+ result = Legion::Apollo::Local.query_by_tags(tags: PROPOSAL_TAG_BASE, limit: 1000)
80
+ return false unless result[:success] && result[:results].is_a?(Array)
81
+
82
+ result[:results].each do |entry|
83
+ data = ::JSON.parse(entry[:content], symbolize_names: true)
84
+ pid = data[:proposal_id]
85
+ next unless pid
86
+
87
+ # Convert string timestamps back to Time objects
88
+ data[:created_at] = Time.parse(data[:created_at].to_s) if data[:created_at]
89
+ data[:resolved_at] = data[:resolved_at] ? Time.parse(data[:resolved_at].to_s) : nil
90
+
91
+ # Ensure status is a symbol
92
+ data[:status] = data[:status].to_sym if data[:status].is_a?(String)
93
+
94
+ @proposals[pid] = data
95
+ rescue StandardError => e
96
+ Legion::Logging.warn "[governance] load_from_apollo parse error: #{e.message}"
97
+ end
98
+
99
+ @proposals.any?
100
+ rescue StandardError => e
101
+ Legion::Logging.warn "[governance] load_from_apollo failed: #{e.message}"
102
+ false
103
+ end
104
+
74
105
  private
75
106
 
107
+ def save_proposal(proposal_id)
108
+ return unless defined?(Legion::Apollo::Local) && Legion::Apollo::Local.started?
109
+
110
+ prop = @proposals[proposal_id]
111
+ return unless prop
112
+
113
+ # Convert to JSON-safe format
114
+ serializable = prop.dup
115
+ serializable[:created_at] = prop[:created_at].is_a?(Time) ? prop[:created_at].iso8601 : prop[:created_at]
116
+ serializable[:resolved_at] = prop[:resolved_at]&.iso8601
117
+
118
+ content = Legion::JSON.dump(serializable)
119
+ tags = PROPOSAL_TAG_BASE + ["category:#{prop[:category]}", "status:#{prop[:status]}"]
120
+
121
+ Legion::Apollo::Local.upsert(
122
+ content: content,
123
+ tags: tags,
124
+ access_scope: 'private',
125
+ identity_principal_id: nil
126
+ )
127
+ rescue StandardError => e
128
+ Legion::Logging.warn "[governance] save_proposal failed: #{e.message}"
129
+ end
130
+
76
131
  def check_resolution(proposal_id)
77
132
  prop = @proposals[proposal_id]
78
133
  total_votes = prop[:votes_for].size + prop[:votes_against].size
@@ -80,11 +135,13 @@ module Legion
80
135
  if Layers.quorum_met?(prop[:votes_for].size, prop[:council_size])
81
136
  prop[:status] = :approved
82
137
  prop[:resolved_at] = Time.now.utc
138
+ save_proposal(proposal_id)
83
139
  :approved
84
140
  elsif Layers.quorum_met?(prop[:votes_against].size, prop[:council_size]) ||
85
141
  total_votes >= prop[:council_size]
86
142
  prop[:status] = :rejected
87
143
  prop[:resolved_at] = Time.now.utc
144
+ save_proposal(proposal_id)
88
145
  :rejected
89
146
  else
90
147
  :pending
@@ -4,7 +4,7 @@ module Legion
4
4
  module Extensions
5
5
  module Agentic
6
6
  module Social
7
- VERSION = '0.1.18'
7
+ VERSION = '0.1.19'
8
8
  end
9
9
  end
10
10
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lex-agentic-social
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.18
4
+ version: 0.1.19
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity