lex-agentic-social 0.1.18 → 0.1.20
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 +4 -4
- data/CHANGELOG.md +16 -0
- data/lib/legion/extensions/agentic/social/attachment/runners/attachment.rb +1 -1
- data/lib/legion/extensions/agentic/social/calibration/helpers/calibration_registry.rb +40 -0
- data/lib/legion/extensions/agentic/social/calibration/runners/calibration.rb +7 -1
- data/lib/legion/extensions/agentic/social/calibration.rb +1 -0
- data/lib/legion/extensions/agentic/social/conflict/helpers/conflict_log.rb +36 -0
- data/lib/legion/extensions/agentic/social/conflict/runners/conflict.rb +1 -0
- data/lib/legion/extensions/agentic/social/consent/helpers/consent_map.rb +44 -18
- data/lib/legion/extensions/agentic/social/consent/runners/consent.rb +1 -1
- data/lib/legion/extensions/agentic/social/governance/helpers/proposal.rb +62 -5
- data/lib/legion/extensions/agentic/social/governance/runners/governance.rb +1 -1
- data/lib/legion/extensions/agentic/social/trust/helpers/trust_map.rb +18 -9
- data/lib/legion/extensions/agentic/social/version.rb +1 -1
- data/spec/legion/extensions/agentic/social/calibration/helpers/calibration_registry_spec.rb +124 -0
- data/spec/legion/extensions/agentic/social/trust/helpers/trust_map_spec.rb +66 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a649d923310d4a98480e6069c03d586585c2a27d3045c625f5c76d608b066016
|
|
4
|
+
data.tar.gz: d5983148aebfa19722b6ae1c5c4e564bd7be94455f3838401bbbe36cdbc087fe
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: cc6fc74f2620bc3e2cd96c2ba567f61dd0d1596127e33c75db88af80048879944d8d8719f861c7d2b9368d0d53a87f4ea2978c0c7e0e52b949f91b4168b5d650
|
|
7
|
+
data.tar.gz: e6de6e946ebb7468dd4adf14e35e97e6104cfd13f288b7730cd1f92420764713ea0afb4ed6c3a137f2146765da060de24e0e41a70a6000a9c73148c3d904b3f8
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.1.20] - 2026-07-16
|
|
4
|
+
### Added
|
|
5
|
+
- `CalibrationRegistry`: per-identity `CalibrationStore` instances keyed by identity string, fixing the grading-contamination bug where one interlocutor's reactions moved another's calibration weights.
|
|
6
|
+
- `CalibrationRegistry#erase_partner!(identity:)`: removes all calibration data for a given identity, leaving others untouched.
|
|
7
|
+
- `CalibrationRegistry#known_identities`: returns all registered identity keys.
|
|
8
|
+
- `CalibrationRegistry#to_apollo_entries_for(identity:)`: returns Apollo persistence entries scoped to one identity.
|
|
9
|
+
- `TrustMap` gains `partner_identity:` keyword on `get`, `get_or_create`, and `record_interaction` — entries are keyed as `(partner_identity, agent_id, domain)` for H7 scoping.
|
|
10
|
+
- `TrustMap#erase_partner!(identity:)`: removes all trust entries scoped to the given partner identity and marks the map dirty.
|
|
11
|
+
|
|
12
|
+
## [0.1.19] - 2026-06-01
|
|
13
|
+
### Fixed
|
|
14
|
+
- Calibration LLM preference extraction now uses `Legion::LLM.chat` with explicit system/user messages instead of legacy `Legion::LLM.ask`.
|
|
15
|
+
- Governance Proposal store now persists to Apollo Local on create, vote, and resolution — safety-critical proposal state survives restarts.
|
|
16
|
+
- ConsentMap changes (tier changes, action outcomes, pending approvals) are now saved to local storage automatically on every mutation.
|
|
17
|
+
- ConflictLog now evicts old resolved conflicts (30-day retention) and enforces a 1000-entry cap to prevent unbounded memory growth.
|
|
18
|
+
|
|
3
19
|
## [0.1.18] - 2026-05-29
|
|
4
20
|
### Added
|
|
5
21
|
- Database index on `trust_entries.domain` column for faster filtered domain lookups
|
|
@@ -25,7 +25,7 @@ module Legion
|
|
|
25
25
|
{ agents_updated: agents.size, models: agents.map { |id| attachment_store.get(id)&.to_h } }
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
-
def reflect_on_bonds(
|
|
28
|
+
def reflect_on_bonds(**)
|
|
29
29
|
store = apollo_local_store
|
|
30
30
|
return { success: false, error: :no_store } unless store
|
|
31
31
|
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Agentic
|
|
6
|
+
module Social
|
|
7
|
+
module Calibration
|
|
8
|
+
module Helpers
|
|
9
|
+
class CalibrationRegistry
|
|
10
|
+
def initialize
|
|
11
|
+
@stores = {}
|
|
12
|
+
@mutex = Mutex.new
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def for(identity:)
|
|
16
|
+
@mutex.synchronize { @stores[identity.to_s] ||= CalibrationStore.new }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def erase_partner!(identity:)
|
|
20
|
+
@mutex.synchronize { @stores.delete(identity.to_s) }
|
|
21
|
+
true
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def known_identities
|
|
25
|
+
@mutex.synchronize { @stores.keys.dup }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def to_apollo_entries_for(identity:)
|
|
29
|
+
store = @mutex.synchronize { @stores[identity.to_s] }
|
|
30
|
+
return [] unless store
|
|
31
|
+
|
|
32
|
+
store.to_apollo_entries
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -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.
|
|
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 =
|
|
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]
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
|
@@ -10,7 +10,7 @@ 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 check_consent(domain:,
|
|
13
|
+
def check_consent(domain:, **)
|
|
14
14
|
tier = consent_map.get_tier(domain)
|
|
15
15
|
log.debug "[consent] check: domain=#{domain} tier=#{tier} allowed=#{tier == :autonomous}"
|
|
16
16
|
|
|
@@ -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
|
|
@@ -61,7 +61,7 @@ module Legion
|
|
|
61
61
|
validate_action(layer: :agent_validation, action: action, _context: context)
|
|
62
62
|
end
|
|
63
63
|
|
|
64
|
-
def validate_action(layer:, action: nil,
|
|
64
|
+
def validate_action(layer:, action: nil, **)
|
|
65
65
|
return { error: :invalid_layer } unless Helpers::Layers.valid_layer?(layer)
|
|
66
66
|
|
|
67
67
|
log.info "[governance] validating action=#{action} layer=#{layer}"
|
|
@@ -10,7 +10,7 @@ module Legion
|
|
|
10
10
|
attr_reader :entries
|
|
11
11
|
|
|
12
12
|
def initialize
|
|
13
|
-
@entries = {} # key: "agent_id:domain"
|
|
13
|
+
@entries = {} # key: "partner_identity|agent_id:domain"
|
|
14
14
|
@dirty = false
|
|
15
15
|
end
|
|
16
16
|
|
|
@@ -23,16 +23,16 @@ module Legion
|
|
|
23
23
|
self
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
-
def get(agent_id, domain: :general)
|
|
27
|
-
@entries[key(agent_id, domain)]
|
|
26
|
+
def get(agent_id, domain: :general, partner_identity: nil)
|
|
27
|
+
@entries[key(agent_id, domain, partner_identity)]
|
|
28
28
|
end
|
|
29
29
|
|
|
30
|
-
def get_or_create(agent_id, domain: :general)
|
|
31
|
-
@entries[key(agent_id, domain)] ||= TrustModel.new_trust_entry(agent_id: agent_id, domain: domain)
|
|
30
|
+
def get_or_create(agent_id, domain: :general, partner_identity: nil)
|
|
31
|
+
@entries[key(agent_id, domain, partner_identity)] ||= TrustModel.new_trust_entry(agent_id: agent_id, domain: domain)
|
|
32
32
|
end
|
|
33
33
|
|
|
34
|
-
def record_interaction(agent_id, positive:, domain: :general)
|
|
35
|
-
entry = get_or_create(agent_id, domain: domain)
|
|
34
|
+
def record_interaction(agent_id, positive:, domain: :general, partner_identity: nil)
|
|
35
|
+
entry = get_or_create(agent_id, domain: domain, partner_identity: partner_identity)
|
|
36
36
|
entry[:interaction_count] += 1
|
|
37
37
|
entry[:last_interaction] = Time.now.utc
|
|
38
38
|
|
|
@@ -53,6 +53,14 @@ module Legion
|
|
|
53
53
|
entry
|
|
54
54
|
end
|
|
55
55
|
|
|
56
|
+
def erase_partner!(identity:)
|
|
57
|
+
prefix = "#{identity}|"
|
|
58
|
+
removed = @entries.keys.select { |k| k.start_with?(prefix) }
|
|
59
|
+
removed.each { |k| @entries.delete(k) }
|
|
60
|
+
@dirty = true if removed.any?
|
|
61
|
+
removed.size
|
|
62
|
+
end
|
|
63
|
+
|
|
56
64
|
def reinforce_dimension(agent_id, dimension:, domain: :general, amount: TrustModel::TRUST_REINFORCEMENT)
|
|
57
65
|
entry = get_or_create(agent_id, domain: domain)
|
|
58
66
|
return unless TrustModel::TRUST_DIMENSIONS.include?(dimension)
|
|
@@ -121,8 +129,9 @@ module Legion
|
|
|
121
129
|
|
|
122
130
|
private
|
|
123
131
|
|
|
124
|
-
def key(agent_id, domain)
|
|
125
|
-
"#{agent_id}:#{domain}"
|
|
132
|
+
def key(agent_id, domain, partner_identity = nil)
|
|
133
|
+
base = "#{agent_id}:#{domain}"
|
|
134
|
+
partner_identity ? "#{partner_identity}|#{base}" : base
|
|
126
135
|
end
|
|
127
136
|
|
|
128
137
|
def build_apollo_tags(agent_id, domain)
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
|
|
5
|
+
RSpec.describe Legion::Extensions::Agentic::Social::Calibration::Helpers::CalibrationRegistry do
|
|
6
|
+
subject(:registry) { described_class.new }
|
|
7
|
+
|
|
8
|
+
let(:identity_x) { 'user:alice' }
|
|
9
|
+
let(:identity_y) { 'user:bob' }
|
|
10
|
+
|
|
11
|
+
describe '#for(identity:)' do
|
|
12
|
+
it 'returns a CalibrationStore instance' do
|
|
13
|
+
expect(registry.for(identity: identity_x)).to be_a(
|
|
14
|
+
Legion::Extensions::Agentic::Social::Calibration::Helpers::CalibrationStore
|
|
15
|
+
)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
it 'returns the same instance on repeated calls for the same identity' do
|
|
19
|
+
first = registry.for(identity: identity_x)
|
|
20
|
+
second = registry.for(identity: identity_x)
|
|
21
|
+
expect(first).to equal(second)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
it 'returns independent instances per identity' do
|
|
25
|
+
x = registry.for(identity: identity_x)
|
|
26
|
+
y = registry.for(identity: identity_y)
|
|
27
|
+
expect(x).not_to equal(y)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
it 'X reads never see Y data — weights are independent' do
|
|
31
|
+
store_x = registry.for(identity: identity_x)
|
|
32
|
+
store_x.record_advisory(advisory_id: 'ax', advisory_types: [:tone_adjustment])
|
|
33
|
+
store_x.evaluate_reaction(observation: { content: 'thanks, perfect!', direct_address: true, content_length: 20 })
|
|
34
|
+
|
|
35
|
+
store_y = registry.for(identity: identity_y)
|
|
36
|
+
expect(store_y.weights['tone_adjustment']).to eq(0.5)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
it 'Y negative reaction never moves X calibration weights (contamination guard)' do
|
|
40
|
+
store_x = registry.for(identity: identity_x)
|
|
41
|
+
store_x.record_advisory(advisory_id: 'ax', advisory_types: [:tone_adjustment])
|
|
42
|
+
|
|
43
|
+
store_y = registry.for(identity: identity_y)
|
|
44
|
+
store_y.record_advisory(advisory_id: 'ay', advisory_types: [:tone_adjustment])
|
|
45
|
+
store_y.evaluate_reaction(observation: { content: 'no, wrong', content_length: 5 })
|
|
46
|
+
|
|
47
|
+
# X advisory meta should still be present (Y's reaction didn't consume X's advisory)
|
|
48
|
+
result = store_x.evaluate_reaction(observation: { content: 'hello' })
|
|
49
|
+
expect(result).not_to be_nil
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
describe '#erase_partner!(identity:)' do
|
|
54
|
+
before do
|
|
55
|
+
store = registry.for(identity: identity_x)
|
|
56
|
+
store.record_advisory(advisory_id: 'ax', advisory_types: [:tone_adjustment])
|
|
57
|
+
store.evaluate_reaction(observation: { content: 'thanks' })
|
|
58
|
+
|
|
59
|
+
registry.for(identity: identity_y)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
it 'removes the store for the given identity' do
|
|
63
|
+
registry.erase_partner!(identity: identity_x)
|
|
64
|
+
# A new store is created — weights reset to neutral
|
|
65
|
+
new_store = registry.for(identity: identity_x)
|
|
66
|
+
expect(new_store.weights['tone_adjustment']).to eq(0.5)
|
|
67
|
+
expect(new_store.history).to be_empty
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
it 'leaves other identities untouched' do
|
|
71
|
+
store_y = registry.for(identity: identity_y)
|
|
72
|
+
original_y = store_y.object_id
|
|
73
|
+
registry.erase_partner!(identity: identity_x)
|
|
74
|
+
expect(registry.for(identity: identity_y).object_id).to eq(original_y)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
it 'returns true' do
|
|
78
|
+
expect(registry.erase_partner!(identity: identity_x)).to be true
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
it 'returns true even when identity does not exist' do
|
|
82
|
+
expect(registry.erase_partner!(identity: 'unknown')).to be true
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
describe '#known_identities' do
|
|
87
|
+
it 'returns empty array when no identities registered' do
|
|
88
|
+
expect(registry.known_identities).to be_empty
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
it 'returns registered identities' do
|
|
92
|
+
registry.for(identity: identity_x)
|
|
93
|
+
registry.for(identity: identity_y)
|
|
94
|
+
expect(registry.known_identities).to contain_exactly(identity_x, identity_y)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
it 'does not include erased identities' do
|
|
98
|
+
registry.for(identity: identity_x)
|
|
99
|
+
registry.erase_partner!(identity: identity_x)
|
|
100
|
+
expect(registry.known_identities).not_to include(identity_x)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
describe 'boot-order / idempotent registration' do
|
|
105
|
+
it 're-registering an already-registered identity is a no-op (no duplicate store)' do
|
|
106
|
+
first = registry.for(identity: identity_x)
|
|
107
|
+
second = registry.for(identity: identity_x)
|
|
108
|
+
expect(first).to equal(second)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
describe 'fail-closed guard' do
|
|
113
|
+
it 'to_apollo_entries_for returns empty array when identity not registered' do
|
|
114
|
+
expect(registry.to_apollo_entries_for(identity: 'unregistered')).to eq([])
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
it 'to_apollo_entries_for returns entries for a registered identity' do
|
|
118
|
+
registry.for(identity: identity_x)
|
|
119
|
+
entries = registry.to_apollo_entries_for(identity: identity_x)
|
|
120
|
+
expect(entries).to be_an(Array)
|
|
121
|
+
expect(entries).not_to be_empty
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -296,4 +296,70 @@ RSpec.describe Legion::Extensions::Agentic::Social::Trust::Helpers::TrustMap do
|
|
|
296
296
|
expect(map.count).to eq(3)
|
|
297
297
|
end
|
|
298
298
|
end
|
|
299
|
+
|
|
300
|
+
describe 'partner_identity keying (H0)' do
|
|
301
|
+
let(:partner_x) { 'user:alice' }
|
|
302
|
+
let(:partner_y) { 'user:bob' }
|
|
303
|
+
|
|
304
|
+
it 'entries scoped to partner_x are not visible under partner_y' do
|
|
305
|
+
map.record_interaction(agent_id, positive: true, partner_identity: partner_x)
|
|
306
|
+
expect(map.get(agent_id, partner_identity: partner_y)).to be_nil
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
it 'independent entries exist per partner_identity' do
|
|
310
|
+
map.record_interaction(agent_id, positive: true, partner_identity: partner_x)
|
|
311
|
+
map.record_interaction(agent_id, positive: false, partner_identity: partner_y)
|
|
312
|
+
expect(map.get(agent_id, partner_identity: partner_x)[:composite]).to be >
|
|
313
|
+
map.get(agent_id, partner_identity: partner_y)[:composite]
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
it 'entries keyed without partner_identity are independent from partner-scoped entries' do
|
|
317
|
+
map.record_interaction(agent_id, positive: true)
|
|
318
|
+
map.record_interaction(agent_id, positive: false, partner_identity: partner_x)
|
|
319
|
+
expect(map.get(agent_id)[:composite]).not_to eq(map.get(agent_id, partner_identity: partner_x)[:composite])
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
it 'count includes all partner-scoped entries' do
|
|
323
|
+
map.get_or_create(agent_id, partner_identity: partner_x)
|
|
324
|
+
map.get_or_create(agent_id, partner_identity: partner_y)
|
|
325
|
+
expect(map.count).to eq(2)
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
describe '#erase_partner!(identity:)' do
|
|
330
|
+
let(:partner_x) { 'user:alice' }
|
|
331
|
+
let(:partner_y) { 'user:bob' }
|
|
332
|
+
|
|
333
|
+
before do
|
|
334
|
+
map.record_interaction(agent_id, positive: true, partner_identity: partner_x)
|
|
335
|
+
map.record_interaction(agent_id, positive: true, partner_identity: partner_y)
|
|
336
|
+
map.record_interaction('other-agent', positive: true, partner_identity: partner_x)
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
it 'removes all entries for the given partner_identity' do
|
|
340
|
+
map.erase_partner!(identity: partner_x)
|
|
341
|
+
expect(map.get(agent_id, partner_identity: partner_x)).to be_nil
|
|
342
|
+
expect(map.get('other-agent', partner_identity: partner_x)).to be_nil
|
|
343
|
+
end
|
|
344
|
+
|
|
345
|
+
it 'leaves entries for other partner identities untouched' do
|
|
346
|
+
map.erase_partner!(identity: partner_x)
|
|
347
|
+
expect(map.get(agent_id, partner_identity: partner_y)).not_to be_nil
|
|
348
|
+
end
|
|
349
|
+
|
|
350
|
+
it 'marks the map as dirty' do
|
|
351
|
+
map.mark_clean!
|
|
352
|
+
map.erase_partner!(identity: partner_x)
|
|
353
|
+
expect(map).to be_dirty
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
it 'returns the count of removed entries' do
|
|
357
|
+
count = map.erase_partner!(identity: partner_x)
|
|
358
|
+
expect(count).to eq(2)
|
|
359
|
+
end
|
|
360
|
+
|
|
361
|
+
it 'returns 0 when no entries exist for that identity' do
|
|
362
|
+
expect(map.erase_partner!(identity: 'unknown')).to eq(0)
|
|
363
|
+
end
|
|
364
|
+
end
|
|
299
365
|
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.
|
|
4
|
+
version: 0.1.20
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Esity
|
|
@@ -192,6 +192,7 @@ files:
|
|
|
192
192
|
- lib/legion/extensions/agentic/social/attachment/version.rb
|
|
193
193
|
- lib/legion/extensions/agentic/social/calibration.rb
|
|
194
194
|
- lib/legion/extensions/agentic/social/calibration/client.rb
|
|
195
|
+
- lib/legion/extensions/agentic/social/calibration/helpers/calibration_registry.rb
|
|
195
196
|
- lib/legion/extensions/agentic/social/calibration/helpers/calibration_store.rb
|
|
196
197
|
- lib/legion/extensions/agentic/social/calibration/helpers/constants.rb
|
|
197
198
|
- lib/legion/extensions/agentic/social/calibration/runners/calibration.rb
|
|
@@ -335,6 +336,7 @@ files:
|
|
|
335
336
|
- spec/legion/extensions/agentic/social/attachment/helpers/attachment_store_spec.rb
|
|
336
337
|
- spec/legion/extensions/agentic/social/attachment/helpers/constants_spec.rb
|
|
337
338
|
- spec/legion/extensions/agentic/social/attachment/runners/attachment_spec.rb
|
|
339
|
+
- spec/legion/extensions/agentic/social/calibration/helpers/calibration_registry_spec.rb
|
|
338
340
|
- spec/legion/extensions/agentic/social/calibration/helpers/calibration_store_spec.rb
|
|
339
341
|
- spec/legion/extensions/agentic/social/calibration/helpers/constants_spec.rb
|
|
340
342
|
- spec/legion/extensions/agentic/social/calibration/runners/calibration_spec.rb
|