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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c669852940dd9abfa0890d908ed6520df5ba9aa89dad4fc58fadd28a8ecd8529
4
- data.tar.gz: 65832a10c828873e86131c0e1c7adbc23708a70f607ace40f73e1b4aa457dd33
3
+ metadata.gz: a649d923310d4a98480e6069c03d586585c2a27d3045c625f5c76d608b066016
4
+ data.tar.gz: d5983148aebfa19722b6ae1c5c4e564bd7be94455f3838401bbbe36cdbc087fe
5
5
  SHA512:
6
- metadata.gz: 821f62c6dd7086ab97cc23958319080e4b6fcd8eddb2f59929bc6c9c9fa24291ef3c2d32eedf08767f67a5cbbad312a9ea9e12703429060a9d3aa3a4d2327284
7
- data.tar.gz: 83751ac84ae50a28275479b1ef216f40ec43de7d75f45116e7e383ca649f73763da16b00ca9737ab76d67a23bcf9f3352841c8c49f806dc745874e3f334ab136
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(_tick_results: {}, _bond_summary: {}, **)
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.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)
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative 'calibration/helpers/constants'
4
4
  require_relative 'calibration/helpers/calibration_store'
5
+ require_relative 'calibration/helpers/calibration_registry'
5
6
  require_relative 'calibration/runners/calibration'
6
7
  require_relative 'calibration/client'
7
8
 
@@ -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
 
@@ -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:, _action_type: :general, **)
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, _context: {}, **)
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)
@@ -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.20'
8
8
  end
9
9
  end
10
10
  end
@@ -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.18
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