lex-agentic-social 0.1.20 → 0.2.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: a649d923310d4a98480e6069c03d586585c2a27d3045c625f5c76d608b066016
4
- data.tar.gz: d5983148aebfa19722b6ae1c5c4e564bd7be94455f3838401bbbe36cdbc087fe
3
+ metadata.gz: c6d1a18883c8b134af22df999670a6f0cd58ad633c193af3ae9d04cf05cad356
4
+ data.tar.gz: 9c83991c4a5787d6b2f75a21cd199d28eac62e823e00d76d8a88240464c02f93
5
5
  SHA512:
6
- metadata.gz: cc6fc74f2620bc3e2cd96c2ba567f61dd0d1596127e33c75db88af80048879944d8d8719f861c7d2b9368d0d53a87f4ea2978c0c7e0e52b949f91b4168b5d650
7
- data.tar.gz: e6de6e946ebb7468dd4adf14e35e97e6104cfd13f288b7730cd1f92420764713ea0afb4ed6c3a137f2146765da060de24e0e41a70a6000a9c73148c3d904b3f8
6
+ metadata.gz: 27f56a7138662a32573fef178cffebd154d386bb620ec48eedf59248a4f8412e1ce12ee70a4a3598725678c8d1e9baa88076fe2f9888834af2c3c7fa480df105
7
+ data.tar.gz: 32e06a56b8c36e1f70dca344ce6836ba502f4ac4d1f7a1b32ca9535c9e82fb2d89dae0cc6b851b795103f67f780f17b88f7fd120cb9b0341127061a6512e18f0
data/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.2.0] - 2026-10-29
4
+ ### Changed
5
+ - Scope calibration by human and match pending feedback to existing exchange attribution. Identified feedback cannot consume legacy unattributed receipts; callers must pass matching identity and applied exchange context.
6
+ - Preserve partner identity in serialized trust state. Older readers that discard this identity are not safe rollback targets for scoped trust data.
7
+ ### Fixed
8
+ - Reject ambiguous and duplicate feedback; evaluate against prior baselines and prioritize explicit corrections over weak timing/length cues.
9
+ - Stop treating delayed or terse replies as negative evidence, and leave missing measurements out of baseline updates.
10
+ - Honor partner-scoped trust during recording, reinforcement, lookup, and delegation queries.
11
+ - Reject expired consent approvals synchronously and invalidate pending approval after a tier change without affecting other domains.
12
+
13
+ ## [0.1.21] - 2026-07-16
14
+ ### Added
15
+ - `CalibrationStore#record_advisory` gains `applied:` kwarg to store which behavioral synapses were applied alongside the advisory.
16
+ - `CalibrationStore#evaluate_reaction` now returns `reaction_score` (Float 0.0–1.0) and `applied` hash in its result hash, enabling legion-gaia's H2 synapse grading path.
17
+ - `Calibration` runner `record_advisory_meta` accepts and passes through the `applied:` kwarg.
18
+ - `Calibration` runner `update_calibration` passes through `reaction_score` and `applied` from the store result.
19
+
3
20
  ## [0.1.20] - 2026-07-16
4
21
  ### Added
5
22
  - `CalibrationRegistry`: per-identity `CalibrationStore` instances keyed by identity string, fixing the grading-contamination bug where one interlocutor's reactions moved another's calibration weights.
data/README.md CHANGED
@@ -5,7 +5,7 @@ Domain consolidation gem for social cognition, governance, and multi-agent inter
5
5
  ## Overview
6
6
 
7
7
  **Gem**: `lex-agentic-social`
8
- **Version**: 0.1.14
8
+ **Version**: 0.2.0
9
9
  **Namespace**: `Legion::Extensions::Agentic::Social`
10
10
 
11
11
  ## Sub-Modules
@@ -48,6 +48,27 @@ Domain consolidation gem for social cognition, governance, and multi-agent inter
48
48
 
49
49
  `Social::Consent` governs whether actions require human approval — the HITL gate must not be bypassed. `Social::Governance` provides the council approval mechanism used by `Defense::Extinction` for containment escalation.
50
50
 
51
+ ## Scoped calibration and trust
52
+
53
+ Pass a verified `identity:` when recording an advisory, or include it in the
54
+ existing `applied:` hash. Carry the same identity and available `exchange_id`
55
+ or `conversation_id` into the feedback observation. Multiple pending
56
+ advisories are matched by this attribution; ambiguous, mismatched, expired,
57
+ or repeated feedback cannot reinforce another response.
58
+
59
+ `Calibration::Client.new(calibration_registry: registry)` shares an explicitly
60
+ owned registry across clients. Without injection, clients remain independent.
61
+ Legacy unscoped receipts only accept unscoped feedback. GAIA must supply the
62
+ matching receipt attribution before identified feedback can be graded.
63
+ Receipt deduplication is process-local within the existing one-hour lifetime,
64
+ not durable exactly-once processing.
65
+
66
+ Trust runner methods accept `partner_identity:` independently of the worker's
67
+ `agent_id:`. Scoped trust is preserved through Apollo serialization and is
68
+ separate from legacy unscoped trust. Do not roll persisted scoped trust back
69
+ to readers that discard partner identity: they can merge separate people.
70
+ Calibration and trust do not grant consent or tool authority.
71
+
51
72
  ## Installation
52
73
 
53
74
  ```ruby
@@ -8,13 +8,10 @@ module Legion
8
8
  class Client
9
9
  include Runners::Calibration
10
10
 
11
- def initialize(**)
12
- @calibration_store = Helpers::CalibrationStore.new
11
+ def initialize(calibration_store: nil, calibration_registry: nil, **)
12
+ @calibration_store = calibration_store || Legion::Extensions::Agentic::Social::Calibration::Helpers::CalibrationStore.new
13
+ @calibration_registry = calibration_registry || Legion::Extensions::Agentic::Social::Calibration::Helpers::CalibrationRegistry.new
13
14
  end
14
-
15
- private
16
-
17
- attr_reader :calibration_store
18
15
  end
19
16
  end
20
17
  end
@@ -14,7 +14,8 @@ module Legion
14
14
  [type.to_s, Constants::NEUTRAL_SCORE]
15
15
  end
16
16
  @history = {}
17
- @last_advisory_meta = nil
17
+ @pending_advisories = {}
18
+ @evaluated_advisories = {}
18
19
  @partner_baselines = { 'avg_latency' => nil, 'avg_length' => nil }
19
20
  @dirty = false
20
21
  end
@@ -28,31 +29,41 @@ module Legion
28
29
  self
29
30
  end
30
31
 
31
- def record_advisory(advisory_id:, advisory_types:)
32
- @last_advisory_meta = {
32
+ def record_advisory(advisory_id:, advisory_types:, applied: nil, identity: nil)
33
+ return if identity && applied&.dig(:identity) && identity.to_s != applied[:identity].to_s
34
+
35
+ expire_advisories!
36
+ return if @evaluated_advisories.key?(advisory_id) || @pending_advisories.key?(advisory_id)
37
+
38
+ attribution = (applied || {}).slice(:identity, :exchange_id, :conversation_id).compact
39
+ attribution[:identity] = identity unless identity.nil?
40
+ @pending_advisories[advisory_id] = {
33
41
  advisory_id: advisory_id,
34
- advisory_types: Array(advisory_types).map(&:to_s),
42
+ advisory_types: Array(advisory_types).map(&:to_s).uniq,
43
+ applied: applied,
44
+ attribution: attribution,
35
45
  timestamp: Time.now.utc
36
46
  }
37
47
  end
38
48
 
39
49
  def evaluate_reaction(observation:)
40
- return nil unless @last_advisory_meta
41
- return nil if @last_advisory_meta[:advisory_types].empty?
50
+ expire_advisories!
51
+ candidates = @pending_advisories.values.select { |meta| matches_observation?(meta, observation) }
52
+ return nil unless candidates.one?
42
53
 
43
- elapsed = Time.now.utc - @last_advisory_meta[:timestamp]
44
- return nil if elapsed > 3600
54
+ meta = candidates.first
55
+ return nil if meta[:advisory_types].empty?
45
56
 
46
57
  reaction_score = compute_reaction_score(observation)
47
58
  confidence = compute_confidence(observation)
48
-
49
- deltas = @last_advisory_meta[:advisory_types].map do |type|
59
+ deltas = meta[:advisory_types].map do |type|
50
60
  apply_delta(type: type, reaction_score: reaction_score, confidence: confidence)
51
61
  end
52
62
 
53
- @last_advisory_meta = nil
63
+ @pending_advisories.delete(meta[:advisory_id])
64
+ @evaluated_advisories[meta[:advisory_id]] = meta[:timestamp]
54
65
  @dirty = true
55
- { success: true, deltas: deltas }
66
+ { success: true, deltas: deltas, reaction_score: reaction_score, applied: meta[:applied] }
56
67
  end
57
68
 
58
69
  def calibration_weights
@@ -61,23 +72,19 @@ module Legion
61
72
 
62
73
  def detect_explicit_feedback(content)
63
74
  text = content.to_s
64
- return :positive if text.match?(Constants::POSITIVE_PATTERNS)
65
75
  return :negative if text.match?(Constants::NEGATIVE_PATTERNS)
76
+ return :positive if text.match?(Constants::POSITIVE_PATTERNS)
66
77
 
67
78
  :neutral
68
79
  end
69
80
 
70
81
  def update_baseline(latency:, length:)
71
- @partner_baselines['avg_latency'] = if @partner_baselines['avg_latency']
72
- (@partner_baselines['avg_latency'] * 0.9) + (latency.to_f * 0.1)
73
- else
74
- latency.to_f
75
- end
76
- @partner_baselines['avg_length'] = if @partner_baselines['avg_length']
77
- (@partner_baselines['avg_length'] * 0.9) + (length.to_f * 0.1)
78
- else
79
- length.to_f
80
- end
82
+ { 'avg_latency' => latency, 'avg_length' => length }.each do |key, value|
83
+ next if value.nil?
84
+
85
+ previous = @partner_baselines[key]
86
+ @partner_baselines[key] = previous ? (previous * 0.9) + (value.to_f * 0.1) : value.to_f
87
+ end
81
88
  end
82
89
 
83
90
  def to_apollo_entries
@@ -118,28 +125,40 @@ module Legion
118
125
 
119
126
  private
120
127
 
128
+ def expire_advisories!
129
+ cutoff = Time.now.utc - 3600
130
+ @pending_advisories.delete_if { |_id, meta| meta[:timestamp] < cutoff }
131
+ @evaluated_advisories.delete_if { |_id, timestamp| timestamp < cutoff }
132
+ end
133
+
134
+ def matches_observation?(meta, observation)
135
+ return false if observation[:advisory_id] && observation[:advisory_id] != meta[:advisory_id]
136
+
137
+ attribution = meta[:attribution]
138
+ return false unless attribution.all? do |key, value|
139
+ !value.to_s.strip.empty? && observation[key] && observation[key].to_s == value.to_s
140
+ end
141
+
142
+ %i[identity exchange_id conversation_id].none? do |key|
143
+ observation[key] && !attribution.key?(key)
144
+ end
145
+ end
146
+
121
147
  def compute_reaction_score(observation)
122
148
  score = 0.0
123
149
 
124
150
  feedback = detect_explicit_feedback(observation[:content] || '')
125
- case feedback
126
- when :positive then score += Constants::SIGNAL_WEIGHTS[:explicit_feedback]
127
- when :negative then score -= Constants::SIGNAL_WEIGHTS[:explicit_feedback]
128
- end
151
+ return (1.0 + Constants::SIGNAL_WEIGHTS[:explicit_feedback]) / 2.0 if feedback == :positive
152
+ return (1.0 - Constants::SIGNAL_WEIGHTS[:explicit_feedback]) / 2.0 if feedback == :negative
129
153
 
130
- if @partner_baselines['avg_latency'] && observation[:latency]
154
+ if @partner_baselines['avg_latency']&.positive? && observation[:latency]
131
155
  ratio = observation[:latency].to_f / @partner_baselines['avg_latency']
132
- latency_signal = if ratio < 0.8
133
- 1.0
134
- else
135
- (ratio > 1.5 ? -1.0 : 0.0)
136
- end
137
- score += Constants::SIGNAL_WEIGHTS[:response_latency] * latency_signal
156
+ score += Constants::SIGNAL_WEIGHTS[:response_latency] if ratio < 0.8
138
157
  end
139
158
 
140
159
  if @partner_baselines['avg_length'] && observation[:content_length]
141
- length_signal = observation[:content_length].to_f / [@partner_baselines['avg_length'], 1].max > 0.5 ? 1.0 : -1.0
142
- score += Constants::SIGNAL_WEIGHTS[:message_length] * length_signal
160
+ ratio = observation[:content_length].to_f / [@partner_baselines['avg_length'], 1].max
161
+ score += Constants::SIGNAL_WEIGHTS[:message_length] if ratio > 0.5
143
162
  end
144
163
 
145
164
  score += Constants::SIGNAL_WEIGHTS[:direct_address] if observation[:direct_address]
@@ -10,22 +10,32 @@ module Legion
10
10
  include Legion::Extensions::Helpers::Lex if defined?(Legion::Extensions::Helpers) &&
11
11
  Legion::Extensions::Helpers.const_defined?(:Lex, false)
12
12
 
13
- def update_calibration(observation: nil, **)
13
+ def update_calibration(observation: nil, identity: nil, **)
14
14
  return { success: true, skipped: :no_observation } unless observation
15
+ return { success: true, skipped: :identity_mismatch } if identity && observation[:identity] && identity.to_s != observation[:identity].to_s
15
16
 
16
- calibration_store.update_baseline(
17
- latency: observation[:latency] || 0,
18
- length: observation[:content_length] || 0
19
- )
17
+ identity ||= observation[:identity]
18
+ return { success: true, skipped: :unattributed_identity } if identity && identity.to_s.strip.empty?
20
19
 
21
- result = calibration_store.evaluate_reaction(observation: observation)
20
+ store = calibration_store(identity: identity)
21
+ observation = observation.merge(identity: identity) if identity
22
+ result = store.evaluate_reaction(observation: observation)
23
+ store.update_baseline(latency: observation[:latency], length: observation[:content_length])
22
24
  return { success: true, skipped: :no_advisory } unless result
23
25
 
24
- { success: true, deltas: result[:deltas], weights: calibration_store.calibration_weights }
26
+ { success: true, deltas: result[:deltas], weights: store.calibration_weights,
27
+ reaction_score: result[:reaction_score], applied: result[:applied] }
25
28
  end
26
29
 
27
- def record_advisory_meta(advisory_id:, advisory_types:, **)
28
- calibration_store.record_advisory(advisory_id: advisory_id, advisory_types: advisory_types)
30
+ def record_advisory_meta(advisory_id:, advisory_types:, applied: nil, identity: nil, **)
31
+ return { success: false, error: :identity_mismatch } if identity && applied&.dig(:identity) && identity.to_s != applied[:identity].to_s
32
+
33
+ identity ||= applied&.dig(:identity)
34
+ return { success: false, error: :unattributed_identity } if identity && identity.to_s.strip.empty?
35
+
36
+ calibration_store(identity: identity).record_advisory(
37
+ advisory_id: advisory_id, advisory_types: advisory_types, applied: applied, identity: identity
38
+ )
29
39
  { success: true }
30
40
  end
31
41
 
@@ -34,16 +44,17 @@ module Legion
34
44
  { success: true, feedback: feedback }
35
45
  end
36
46
 
37
- def calibration_weights(**)
38
- { success: true, weights: calibration_store.calibration_weights }
47
+ def calibration_weights(identity: nil, **)
48
+ { success: true, weights: calibration_store(identity: identity).calibration_weights }
39
49
  end
40
50
 
41
- def calibration_stats(**)
51
+ def calibration_stats(identity: nil, **)
52
+ store = calibration_store(identity: identity)
42
53
  {
43
54
  success: true,
44
- weights: calibration_store.calibration_weights,
45
- history_counts: calibration_store.history.transform_values(&:size),
46
- dirty: calibration_store.dirty?
55
+ weights: store.calibration_weights,
56
+ history_counts: store.history.transform_values(&:size),
57
+ dirty: store.dirty?
47
58
  }
48
59
  end
49
60
 
@@ -104,8 +115,11 @@ module Legion
104
115
 
105
116
  private
106
117
 
107
- def calibration_store
108
- @calibration_store ||= Helpers::CalibrationStore.new
118
+ def calibration_store(identity: nil)
119
+ return @calibration_store ||= Legion::Extensions::Agentic::Social::Calibration::Helpers::CalibrationStore.new if identity.nil?
120
+
121
+ @calibration_registry ||= Legion::Extensions::Agentic::Social::Calibration::Helpers::CalibrationRegistry.new
122
+ @calibration_registry.for(identity: identity)
109
123
  end
110
124
 
111
125
  def should_extract_preferences?
@@ -31,6 +31,11 @@ module Legion
31
31
  ensure_domain(domain)
32
32
  entry = @domains[domain]
33
33
  old_tier = entry[:tier]
34
+ if old_tier != tier
35
+ entry[:pending_tier] = nil
36
+ entry[:pending_since] = nil
37
+ entry[:pending_requested_by] = nil
38
+ end
34
39
  entry[:tier] = tier
35
40
  entry[:last_changed_at] = Time.now.utc
36
41
  entry[:history] << { from: old_tier, to: tier, at: Time.now.utc }
@@ -119,6 +119,11 @@ module Legion
119
119
  map = consent_map
120
120
  return { approved: false, error: 'no_pending_approval' } unless map.pending?(domain)
121
121
 
122
+ if map.pending_expired?(domain)
123
+ map.clear_pending(domain)
124
+ return { approved: false, error: 'approval_expired' }
125
+ end
126
+
122
127
  pending_tier = map.domains[domain][:pending_tier]
123
128
  old_tier = map.get_tier(domain)
124
129
  map.clear_pending(domain)
@@ -28,7 +28,9 @@ module Legion
28
28
  end
29
29
 
30
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)
31
+ @entries[key(agent_id, domain, partner_identity)] ||= TrustModel.new_trust_entry(agent_id: agent_id, domain: domain).tap do |entry|
32
+ entry[:partner_identity] = partner_identity.to_s if partner_identity
33
+ end
32
34
  end
33
35
 
34
36
  def record_interaction(agent_id, positive:, domain: :general, partner_identity: nil)
@@ -61,8 +63,8 @@ module Legion
61
63
  removed.size
62
64
  end
63
65
 
64
- def reinforce_dimension(agent_id, dimension:, domain: :general, amount: TrustModel::TRUST_REINFORCEMENT)
65
- entry = get_or_create(agent_id, domain: domain)
66
+ def reinforce_dimension(agent_id, dimension:, domain: :general, amount: TrustModel::TRUST_REINFORCEMENT, partner_identity: nil)
67
+ entry = get_or_create(agent_id, domain: domain, partner_identity: partner_identity)
66
68
  return unless TrustModel::TRUST_DIMENSIONS.include?(dimension)
67
69
 
68
70
  entry[:dimensions][dimension] = TrustModel.clamp(entry[:dimensions][dimension] + amount)
@@ -84,14 +86,16 @@ module Legion
84
86
  decayed
85
87
  end
86
88
 
87
- def trusted_agents(domain: :general, min_trust: TrustModel::TRUST_CONSIDER_THRESHOLD)
88
- @entries.values
89
- .select { |e| e[:domain] == domain && e[:composite] >= min_trust }
90
- .sort_by { |e| -e[:composite] }
89
+ def trusted_agents(domain: :general, min_trust: TrustModel::TRUST_CONSIDER_THRESHOLD, partner_identity: nil)
90
+ identity = partner_identity&.to_s
91
+ selected = @entries.values.select do |entry|
92
+ entry[:domain] == domain && entry[:composite] >= min_trust && entry[:partner_identity] == identity
93
+ end
94
+ selected.sort_by! { |entry| -entry[:composite] }
91
95
  end
92
96
 
93
- def delegatable_agents(domain: :general)
94
- trusted_agents(domain: domain, min_trust: TrustModel::TRUST_DELEGATE_THRESHOLD)
97
+ def delegatable_agents(domain: :general, partner_identity: nil)
98
+ trusted_agents(domain: domain, min_trust: TrustModel::TRUST_DELEGATE_THRESHOLD, partner_identity: partner_identity)
95
99
  end
96
100
 
97
101
  def count
@@ -101,8 +105,10 @@ module Legion
101
105
  def to_apollo_entries
102
106
  @entries.map do |_key, entry|
103
107
  tags = build_apollo_tags(entry[:agent_id], entry[:domain])
108
+ tags << "partner:#{entry[:partner_identity]}" if entry[:partner_identity]
104
109
  content = Legion::JSON.dump({
105
110
  agent_id: entry[:agent_id].to_s,
111
+ partner_identity: entry[:partner_identity],
106
112
  domain: entry[:domain].to_s,
107
113
  dimensions: entry[:dimensions],
108
114
  composite: entry[:composite],
@@ -155,8 +161,10 @@ module Legion
155
161
  domain_val = (flex(data, 'domain') || 'general').to_sym
156
162
  dims = restore_dimensions(flex(data, 'dimensions') || {})
157
163
 
158
- @entries[key(agent_id, domain_val)] = {
164
+ partner_identity = flex(data, 'partner_identity')
165
+ @entries[key(agent_id, domain_val, partner_identity)] = {
159
166
  agent_id: agent_id,
167
+ partner_identity: partner_identity&.to_s,
160
168
  domain: domain_val,
161
169
  dimensions: dims,
162
170
  composite: (flex(data, 'composite') || TrustModel::NEUTRAL_TRUST).to_f,
@@ -10,8 +10,8 @@ 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 get_trust(agent_id:, domain: :general, **)
14
- entry = trust_map.get(agent_id, domain: domain)
13
+ def get_trust(agent_id:, domain: :general, partner_identity: nil, **)
14
+ entry = trust_map.get(agent_id, domain: domain, partner_identity: partner_identity)
15
15
  if entry
16
16
  log.debug "[trust] get agent=#{agent_id} domain=#{domain} composite=#{entry[:composite].round(2)}"
17
17
  { found: true, trust: entry }
@@ -21,8 +21,8 @@ module Legion
21
21
  end
22
22
  end
23
23
 
24
- def record_trust_interaction(agent_id:, positive:, domain: :general, **)
25
- entry = trust_map.record_interaction(agent_id, domain: domain, positive: positive)
24
+ def record_trust_interaction(agent_id:, positive:, domain: :general, partner_identity: nil, **)
25
+ entry = trust_map.record_interaction(agent_id, domain: domain, positive: positive, partner_identity: partner_identity)
26
26
  msg = "[trust] interaction: agent=#{agent_id} domain=#{domain} positive=#{positive} " \
27
27
  "composite=#{entry[:composite].round(2)} total=#{entry[:interaction_count]}"
28
28
  log.info msg
@@ -35,10 +35,10 @@ module Legion
35
35
  }
36
36
  end
37
37
 
38
- def reinforce_trust_dimension(agent_id:, dimension:, domain: :general, amount: nil, **)
38
+ def reinforce_trust_dimension(agent_id:, dimension:, domain: :general, amount: nil, partner_identity: nil, **)
39
39
  amt = amount || Helpers::TrustModel::TRUST_REINFORCEMENT
40
- trust_map.reinforce_dimension(agent_id, domain: domain, dimension: dimension, amount: amt)
41
- entry = trust_map.get(agent_id, domain: domain)
40
+ trust_map.reinforce_dimension(agent_id, domain: domain, dimension: dimension, amount: amt, partner_identity: partner_identity)
41
+ entry = trust_map.get(agent_id, domain: domain, partner_identity: partner_identity)
42
42
  log.debug "[trust] reinforce: agent=#{agent_id} dimension=#{dimension} amount=#{amt} composite=#{entry[:composite].round(2)}"
43
43
  { agent_id: agent_id, domain: domain, dimension: dimension, composite: entry[:composite] }
44
44
  end
@@ -49,15 +49,15 @@ module Legion
49
49
  { decayed: decayed }
50
50
  end
51
51
 
52
- def trusted_agents(domain: :general, min_trust: nil, **)
52
+ def trusted_agents(domain: :general, min_trust: nil, partner_identity: nil, **)
53
53
  min = min_trust || Helpers::TrustModel::TRUST_CONSIDER_THRESHOLD
54
- agents = trust_map.trusted_agents(domain: domain, min_trust: min)
54
+ agents = trust_map.trusted_agents(domain: domain, min_trust: min, partner_identity: partner_identity)
55
55
  log.debug "[trust] trusted agents: domain=#{domain} min=#{min} count=#{agents.size}"
56
56
  { agents: agents, count: agents.size }
57
57
  end
58
58
 
59
- def delegatable_agents(domain: :general, **)
60
- agents = trust_map.delegatable_agents(domain: domain)
59
+ def delegatable_agents(domain: :general, partner_identity: nil, **)
60
+ agents = trust_map.delegatable_agents(domain: domain, partner_identity: partner_identity)
61
61
  log.debug "[trust] delegatable agents: domain=#{domain} count=#{agents.size}"
62
62
  { agents: agents, count: agents.size }
63
63
  end
@@ -4,7 +4,7 @@ module Legion
4
4
  module Extensions
5
5
  module Agentic
6
6
  module Social
7
- VERSION = '0.1.20'
7
+ VERSION = '0.2.0'
8
8
  end
9
9
  end
10
10
  end
@@ -48,6 +48,19 @@ RSpec.describe Legion::Extensions::Agentic::Social::Calibration::Helpers::Calibr
48
48
  expect(result).not_to be_nil
49
49
  expect(result[:deltas].size).to eq(2)
50
50
  end
51
+
52
+ it 'stores applied kwarg when provided' do
53
+ applied_data = { synapse_ids: [1, 2, 3], strategy: 'tone' }
54
+ store.record_advisory(advisory_id: 'abc', advisory_types: [:tone_adjustment], applied: applied_data)
55
+ result = store.evaluate_reaction(observation: { content: 'thanks' })
56
+ expect(result[:applied]).to eq(applied_data)
57
+ end
58
+
59
+ it 'stores nil applied when not provided' do
60
+ store.record_advisory(advisory_id: 'abc', advisory_types: [:tone_adjustment])
61
+ result = store.evaluate_reaction(observation: { content: 'thanks' })
62
+ expect(result[:applied]).to be_nil
63
+ end
51
64
  end
52
65
 
53
66
  describe '#evaluate_reaction' do
@@ -90,6 +103,125 @@ RSpec.describe Legion::Extensions::Agentic::Social::Calibration::Helpers::Calibr
90
103
  store.evaluate_reaction(observation: { content: 'thanks' })
91
104
  expect(store.history['tone_adjustment']).not_to be_empty
92
105
  end
106
+
107
+ it 'returns reaction_score as a float between 0.0 and 1.0' do
108
+ result = store.evaluate_reaction(observation: { content: 'thanks' })
109
+ expect(result[:reaction_score]).to be_a(Float)
110
+ expect(result[:reaction_score]).to be_between(0.0, 1.0)
111
+ end
112
+
113
+ it 'returns applied from recorded advisory' do
114
+ store.mark_clean!
115
+ applied_data = { synapse_ids: [10, 20] }
116
+ store.record_advisory(advisory_id: 'test2', advisory_types: [:tone_adjustment], applied: applied_data)
117
+ result = store.evaluate_reaction(observation: { advisory_id: 'test2', content: 'ok' })
118
+ expect(result[:applied]).to eq(applied_data)
119
+ end
120
+ end
121
+
122
+ context 'reaction_score signal ranges' do
123
+ it 'returns reaction_score >= 0.6 for positive feedback' do
124
+ store.record_advisory(advisory_id: 'pos', advisory_types: [:tone_adjustment])
125
+ result = store.evaluate_reaction(observation: { content: 'thanks, perfect!', direct_address: true, content_length: 50 })
126
+ expect(result[:reaction_score]).to be >= 0.6
127
+ end
128
+
129
+ it 'returns reaction_score <= 0.4 for negative feedback' do
130
+ store.record_advisory(advisory_id: 'neg', advisory_types: [:tone_adjustment])
131
+ result = store.evaluate_reaction(observation: { content: 'no, wrong, stop', content_length: 5 })
132
+ expect(result[:reaction_score]).to be <= 0.4
133
+ end
134
+
135
+ it 'returns reaction_score between 0.4 and 0.6 for neutral feedback' do
136
+ store.record_advisory(advisory_id: 'neu', advisory_types: [:tone_adjustment])
137
+ result = store.evaluate_reaction(observation: { content: 'I see', content_length: 10 })
138
+ expect(result[:reaction_score]).to be_between(0.4, 0.6)
139
+ end
140
+ end
141
+
142
+ it 'matches out-of-order feedback to its exchange without consuming the newer advisory' do
143
+ first = { identity: 'alice', exchange_id: 'first' }
144
+ second = { identity: 'alice', exchange_id: 'second' }
145
+ store.record_advisory(advisory_id: 'first', advisory_types: [:tone_adjustment], applied: first)
146
+ store.record_advisory(advisory_id: 'second', advisory_types: [:verbosity_adjustment], applied: second)
147
+
148
+ result = store.evaluate_reaction(observation: first.merge(content: 'wrong'))
149
+ expect(result[:applied]).to eq(first)
150
+ expect(store.calibration_weights['tone_adjustment']).to be < 0.5
151
+ expect(store.calibration_weights['verbosity_adjustment']).to eq(0.5)
152
+ expect(store.evaluate_reaction(observation: second.merge(content: 'thanks'))[:applied]).to eq(second)
153
+ expect(store.calibration_weights['verbosity_adjustment']).to be > 0.5
154
+ end
155
+
156
+ it 'leaves ambiguous and unattributed feedback ungraded until the exchange is identified' do
157
+ %w[first second].each do |exchange|
158
+ store.record_advisory(advisory_id: exchange, advisory_types: [:tone_adjustment],
159
+ applied: { identity: 'alice', exchange_id: exchange })
160
+ end
161
+
162
+ expect(store.evaluate_reaction(observation: { identity: 'alice', content: 'wrong' })).to be_nil
163
+ expect(store.evaluate_reaction(observation: { exchange_id: 'first', content: 'wrong' })).to be_nil
164
+ expect(store.calibration_weights['tone_adjustment']).to eq(0.5)
165
+ result = store.evaluate_reaction(observation: { identity: 'alice', exchange_id: 'first', content: 'wrong' })
166
+ expect(result[:deltas].first[:new_weight]).to be < 0.5
167
+ end
168
+
169
+ it 'does not grade an unattributed advisory using an identified human' do
170
+ store.record_advisory(advisory_id: 'legacy', advisory_types: [:tone_adjustment])
171
+ expect(store.evaluate_reaction(observation: { identity: 'bob', content: 'wrong' })).to be_nil
172
+ expect(store.calibration_weights['tone_adjustment']).to eq(0.5)
173
+ end
174
+
175
+ it 'grades an advisory once despite repeated receipt and feedback delivery' do
176
+ applied = { identity: 'alice', exchange_id: 'exchange' }
177
+ 2.times do
178
+ store.record_advisory(advisory_id: 'receipt', advisory_types: %i[tone_adjustment tone_adjustment], applied: applied)
179
+ end
180
+ store.evaluate_reaction(observation: applied.merge(content: 'thanks'))
181
+ weight = store.calibration_weights['tone_adjustment']
182
+ store.record_advisory(advisory_id: 'receipt', advisory_types: [:tone_adjustment], applied: applied)
183
+
184
+ expect(store.evaluate_reaction(observation: applied.merge(content: 'thanks'))).to be_nil
185
+ expect(store.calibration_weights['tone_adjustment']).to eq(weight)
186
+ expect(store.history['tone_adjustment'].size).to eq(1)
187
+ end
188
+
189
+ it 'does not guess which pending legacy advisory a reaction describes' do
190
+ store.record_advisory(advisory_id: 'first', advisory_types: [:tone_adjustment])
191
+ store.record_advisory(advisory_id: 'second', advisory_types: [:verbosity_adjustment])
192
+ expect(store.evaluate_reaction(observation: { content: 'wrong' })).to be_nil
193
+ expect(store.calibration_weights.values).to all(eq(0.5))
194
+ end
195
+
196
+ it 'keeps an explicit correction negative despite polite words and positive weak cues' do
197
+ store.update_baseline(latency: 10, length: 20)
198
+ store.record_advisory(advisory_id: 'correction', advisory_types: [:tone_adjustment])
199
+ result = store.evaluate_reaction(observation: {
200
+ content: 'thanks, but that is wrong', latency: 1,
201
+ content_length: 100, direct_address: true
202
+ })
203
+ expect(result[:reaction_score]).to be < 0.5
204
+ expect(store.calibration_weights['tone_adjustment']).to be < 0.5
205
+ end
206
+
207
+ it 'does not treat delayed terse replies as negative evidence' do
208
+ store.update_baseline(latency: 2, length: 100)
209
+ store.record_advisory(advisory_id: 'delayed', advisory_types: [:tone_adjustment])
210
+ result = store.evaluate_reaction(observation: { content: 'ok', latency: 500, content_length: 2 })
211
+ expect(result[:reaction_score]).to eq(0.5)
212
+ expect(store.calibration_weights['tone_adjustment']).to eq(0.5)
213
+ end
214
+
215
+ it 'expires old receipts without discarding a fresh exchange' do
216
+ now = Time.utc(2026, 10, 29)
217
+ allow(Time).to receive(:now).and_return(now)
218
+ store.record_advisory(advisory_id: 'old', advisory_types: [:tone_adjustment])
219
+ allow(Time).to receive(:now).and_return(now + 3601)
220
+ store.record_advisory(advisory_id: 'fresh', advisory_types: [:verbosity_adjustment])
221
+ expect(store.evaluate_reaction(observation: { advisory_id: 'old', content: 'wrong' })).to be_nil
222
+ store.evaluate_reaction(observation: { advisory_id: 'fresh', content: 'thanks' })
223
+ expect(store.calibration_weights['tone_adjustment']).to eq(0.5)
224
+ expect(store.calibration_weights['verbosity_adjustment']).to be > 0.5
93
225
  end
94
226
  end
95
227
 
@@ -135,6 +267,14 @@ RSpec.describe Legion::Extensions::Agentic::Social::Calibration::Helpers::Calibr
135
267
  parsed = Legion::JSON.parse(weights_entry[:content])
136
268
  expect(parsed[:baselines][:avg_latency]).to be_between(2.0, 4.0)
137
269
  end
270
+
271
+ it 'does not turn missing measurements into a zero baseline' do
272
+ store.update_baseline(latency: 10, length: 100)
273
+ store.update_baseline(latency: nil, length: nil)
274
+ store.record_advisory(advisory_id: 'baseline', advisory_types: [:tone_adjustment])
275
+ result = store.evaluate_reaction(observation: { latency: 7.5, content_length: 48 })
276
+ expect(result[:reaction_score]).to eq(0.6)
277
+ end
138
278
  end
139
279
 
140
280
  describe '#to_apollo_entries' do
@@ -22,6 +22,74 @@ RSpec.describe Legion::Extensions::Agentic::Social::Calibration::Runners::Calibr
22
22
  expect(result[:success]).to be true
23
23
  expect(result[:deltas]).not_to be_empty
24
24
  end
25
+
26
+ it 'returns reaction_score in result when advisory recorded' do
27
+ client.record_advisory_meta(advisory_id: 'test', advisory_types: [:tone_adjustment])
28
+ result = client.update_calibration(observation: { content: 'thanks', direct_address: true, content_length: 20 })
29
+ expect(result).to have_key(:reaction_score)
30
+ expect(result[:reaction_score]).to be_a(Float)
31
+ end
32
+
33
+ it 'returns applied in result when advisory recorded with applied kwarg' do
34
+ applied_data = { synapse_ids: [5, 6] }
35
+ client.record_advisory_meta(advisory_id: 'test', advisory_types: [:tone_adjustment], applied: applied_data)
36
+ result = client.update_calibration(observation: { content: 'thanks', direct_address: true, content_length: 20 })
37
+ expect(result[:applied]).to eq(applied_data)
38
+ end
39
+
40
+ it 'keeps two humans baselines, pending exchanges and weights independent' do
41
+ client.update_calibration(observation: { identity: 'alice', latency: 10 })
42
+ client.update_calibration(observation: { identity: 'bob', latency: 1 })
43
+ client.record_advisory_meta(advisory_id: 'alice-reply', advisory_types: [:tone_adjustment],
44
+ applied: { identity: 'alice', exchange_id: 'a' })
45
+ client.record_advisory_meta(advisory_id: 'bob-reply', advisory_types: [:verbosity_adjustment],
46
+ applied: { identity: 'bob', exchange_id: 'b' })
47
+
48
+ wrong_human = client.update_calibration(observation: { identity: 'bob', exchange_id: 'a', content: 'wrong' })
49
+ expect(wrong_human[:skipped]).to eq(:no_advisory)
50
+ bob = client.update_calibration(observation: { identity: 'bob', exchange_id: 'b', content: 'wrong' })
51
+ alice = client.update_calibration(observation: { identity: 'alice', exchange_id: 'a', latency: 7.9 })
52
+
53
+ expect(bob[:weights]['verbosity_adjustment']).to be < 0.5
54
+ expect(bob[:weights]['tone_adjustment']).to eq(0.5)
55
+ expect(alice[:reaction_score]).to eq(0.6)
56
+ expect(alice[:weights]['tone_adjustment']).to be > 0.5
57
+ expect(alice[:weights]['verbosity_adjustment']).to eq(0.5)
58
+ expect(client.calibration_weights[:weights].values).to all(eq(0.5))
59
+ end
60
+
61
+ it 'does not consume scoped feedback using missing or contradictory identity' do
62
+ client.record_advisory_meta(advisory_id: 'alice', advisory_types: [:tone_adjustment],
63
+ applied: { identity: 'alice', exchange_id: 'a' })
64
+ expect(client.update_calibration(observation: { exchange_id: 'a', content: 'wrong' })[:skipped]).to eq(:no_advisory)
65
+ result = client.update_calibration(identity: 'alice', observation: { identity: 'bob', exchange_id: 'a', content: 'wrong' })
66
+ expect(result[:skipped]).to eq(:identity_mismatch)
67
+ expect(client.calibration_weights(identity: 'alice')[:weights]['tone_adjustment']).to eq(0.5)
68
+ result = client.update_calibration(observation: { identity: 'alice', exchange_id: 'a', content: 'thanks' })
69
+ expect(result[:weights]['tone_adjustment']).to be > 0.5
70
+ end
71
+
72
+ it 'uses an injected registry as the state owner across client instances' do
73
+ registry = Legion::Extensions::Agentic::Social::Calibration::Helpers::CalibrationRegistry.new
74
+ writer = Legion::Extensions::Agentic::Social::Calibration::Client.new(calibration_registry: registry)
75
+ reader = Legion::Extensions::Agentic::Social::Calibration::Client.new(calibration_registry: registry)
76
+ writer.record_advisory_meta(advisory_id: 'shared', identity: 'alice', advisory_types: [:tone_adjustment])
77
+ reader.update_calibration(observation: { identity: 'alice', content: 'wrong' })
78
+
79
+ expect(writer.calibration_weights(identity: 'alice')[:weights]['tone_adjustment']).to be < 0.5
80
+ expect(reader.calibration_stats(identity: 'alice')[:history_counts]['tone_adjustment']).to eq(1)
81
+ expect(client.calibration_weights(identity: 'alice')[:weights]['tone_adjustment']).to eq(0.5)
82
+ end
83
+
84
+ it 'keeps a bare runner on the same scoped contract as clients' do
85
+ runner = Object.new.extend(described_class)
86
+ runner.record_advisory_meta(advisory_id: 'bare', identity: 'alice', advisory_types: [:tone_adjustment])
87
+ runner.update_calibration(observation: { identity: 'bob', content: 'wrong' })
88
+ runner.update_calibration(observation: { identity: 'alice', content: 'thanks' })
89
+
90
+ expect(runner.calibration_weights(identity: 'alice')[:weights]['tone_adjustment']).to be > 0.5
91
+ expect(runner.calibration_weights(identity: 'bob')[:weights]['tone_adjustment']).to eq(0.5)
92
+ end
25
93
  end
26
94
 
27
95
  describe '#record_advisory_meta' do
@@ -29,6 +97,15 @@ RSpec.describe Legion::Extensions::Agentic::Social::Calibration::Runners::Calibr
29
97
  result = client.record_advisory_meta(advisory_id: 'abc', advisory_types: [:partner_hint])
30
98
  expect(result[:success]).to be true
31
99
  end
100
+
101
+ it 'accepts and passes applied kwarg' do
102
+ applied_data = { synapse_ids: [1, 2], strategy: 'verbosity' }
103
+ result = client.record_advisory_meta(advisory_id: 'abc', advisory_types: [:partner_hint], applied: applied_data)
104
+ expect(result[:success]).to be true
105
+ # Verify the applied data flows through to evaluate_reaction
106
+ eval_result = client.update_calibration(observation: { content: 'thanks', content_length: 10 })
107
+ expect(eval_result[:applied]).to eq(applied_data)
108
+ end
32
109
  end
33
110
 
34
111
  describe '#detect_explicit_feedback' do
@@ -136,6 +136,46 @@ RSpec.describe Legion::Extensions::Agentic::Social::Consent::Runners::Consent do
136
136
  expect(result[:approved]).to be false
137
137
  expect(result[:error]).to eq('no_pending_approval')
138
138
  end
139
+
140
+ it 'rejects expired approval without relying on a scheduled expiry sweep' do
141
+ now = Time.utc(2026, 10, 29)
142
+ allow(Time).to receive(:now).and_return(now)
143
+ client.request_autonomous_approval(domain: 'email')
144
+ timeout = Legion::Extensions::Agentic::Social::Consent::Helpers::ConsentMap::APPROVAL_TIMEOUT
145
+ allow(Time).to receive(:now).and_return(now + timeout + 1)
146
+
147
+ expect(client.approve_promotion(domain: 'email', approved_by: 'admin')[:approved]).to be false
148
+ expect(client.check_consent(domain: 'email')[:allowed]).to be false
149
+ expect(client.request_autonomous_approval(domain: 'email')[:requested]).to be true
150
+ end
151
+
152
+ it 'does not let an old approval undo a subsequent demotion in another domain' do
153
+ client.request_autonomous_approval(domain: 'email')
154
+ client.request_autonomous_approval(domain: 'calendar')
155
+ client.apply_tier_change(domain: 'email', new_tier: :human_only)
156
+
157
+ expect(client.approve_promotion(domain: 'email', approved_by: 'admin')[:approved]).to be false
158
+ expect(client.check_consent(domain: 'email')[:human_only]).to be true
159
+ expect(client.approve_promotion(domain: 'calendar', approved_by: 'admin')[:approved]).to be true
160
+ expect(client.check_consent(domain: 'email')[:allowed]).to be false
161
+ end
162
+
163
+ it 'does not grant consent from positive calibration or trust feedback' do
164
+ calibration = Legion::Extensions::Agentic::Social::Calibration::Client.new
165
+ trust = Legion::Extensions::Agentic::Social::Trust::Client.new
166
+ client.apply_tier_change(domain: 'email', new_tier: :human_only)
167
+ client.request_autonomous_approval(domain: 'calendar')
168
+ 20.times do |index|
169
+ calibration.record_advisory_meta(advisory_id: index, identity: 'alice', advisory_types: [:tone_adjustment])
170
+ calibration.update_calibration(observation: { identity: 'alice', content: 'thanks' })
171
+ trust.record_trust_interaction(agent_id: 'alice', positive: true)
172
+ end
173
+
174
+ expect(client.check_consent(domain: 'email')[:human_only]).to be true
175
+ expect(client.check_consent(domain: 'calendar')[:allowed]).to be false
176
+ expect(client.approve_promotion(domain: 'calendar', approved_by: 'admin')[:approved]).to be true
177
+ expect(client.check_consent(domain: 'email')[:allowed]).to be false
178
+ end
139
179
  end
140
180
 
141
181
  describe '#reject_promotion' do
@@ -324,6 +324,23 @@ RSpec.describe Legion::Extensions::Agentic::Social::Trust::Helpers::TrustMap do
324
324
  map.get_or_create(agent_id, partner_identity: partner_y)
325
325
  expect(map.count).to eq(2)
326
326
  end
327
+
328
+ it 'preserves scoped trust across serialization without overwriting legacy or another partner' do
329
+ map.record_interaction(agent_id, positive: true, partner_identity: partner_x)
330
+ map.record_interaction(agent_id, positive: false, partner_identity: partner_y)
331
+ map.record_interaction(agent_id, positive: true)
332
+ restored = described_class.new
333
+ persisted = double('apollo', query: { success: true, results: map.to_apollo_entries })
334
+ expect(restored.from_apollo(store: persisted)).to be true
335
+
336
+ expect(restored.get(agent_id, partner_identity: partner_x)[:positive_count]).to eq(1)
337
+ expect(restored.get(agent_id, partner_identity: partner_y)[:negative_count]).to eq(1)
338
+ expect(restored.get(agent_id)[:positive_count]).to eq(1)
339
+ restored.erase_partner!(identity: partner_x)
340
+ expect(restored.get(agent_id, partner_identity: partner_x)).to be_nil
341
+ expect(restored.get(agent_id, partner_identity: partner_y)[:negative_count]).to eq(1)
342
+ expect(restored.get(agent_id)[:positive_count]).to eq(1)
343
+ end
327
344
  end
328
345
 
329
346
  describe '#erase_partner!(identity:)' do
@@ -44,6 +44,22 @@ RSpec.describe Legion::Extensions::Agentic::Social::Trust::Runners::Trust do
44
44
 
45
45
  expect(code_trust[:trust][:composite]).to be > ops_trust[:trust][:composite]
46
46
  end
47
+
48
+ it 'isolates partner trust through recording, reinforcement and delegation queries' do
49
+ 20.times do
50
+ client.record_trust_interaction(agent_id: 'worker', positive: true, partner_identity: 'alice')
51
+ end
52
+ client.record_trust_interaction(agent_id: 'worker', positive: false, partner_identity: 'bob')
53
+ alice_before = client.get_trust(agent_id: 'worker', partner_identity: 'alice')[:trust][:composite]
54
+ client.reinforce_trust_dimension(agent_id: 'worker', dimension: :competence, amount: 0.1, partner_identity: 'bob')
55
+
56
+ expect(client.get_trust(agent_id: 'worker', partner_identity: 'alice')[:trust][:composite]).to eq(alice_before)
57
+ expect(client.get_trust(agent_id: 'worker', partner_identity: 'bob')[:trust][:interaction_count]).to eq(1)
58
+ expect(client.get_trust(agent_id: 'worker')[:found]).to be false
59
+ expect(client.delegatable_agents(partner_identity: 'alice')[:agents].map { |entry| entry[:agent_id] }).to eq(['worker'])
60
+ expect(client.trusted_agents(partner_identity: 'bob')[:agents]).to be_empty
61
+ expect(client.trusted_agents[:agents]).to be_empty
62
+ end
47
63
  end
48
64
 
49
65
  describe '#reinforce_trust_dimension' do
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.20
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity