lex-agentic-social 0.1.7 → 0.1.8

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: 2a637b1280b8b3159e22384a36f90577406b8f3531189100d5d379cadd2dc00b
4
- data.tar.gz: 16d221421b1212af948a0afffa6bb54e149087b13b4d473f1dcfe4cdd459ba27
3
+ metadata.gz: 9b5652d5875cf5aae7e51adf8b8b09f0e664a719144a7575ac9eb2f4bd7b7b56
4
+ data.tar.gz: 62101c073d3970708e278b2e7afd1dbbe3efc582eb03246bf19f975ad1999bd4
5
5
  SHA512:
6
- metadata.gz: 94e0f0c04272c236d18011f3b8bb63798f06be346335bb0dcebac5472439f927817465d2f07bffa5c0633302c0d3bdb8b8a64bb7c30b3d7633a6b38e8cb52029
7
- data.tar.gz: 5fbd869c1827ee00431b24d9ac317d001dfd4af19926a241f70f06cccad44656467585b6adea2499df1938524151dc096be4ceac3a784c34d52d8461fc744920
6
+ metadata.gz: 5e210bfd1a546461a34290e84e49a893cbb792c1d304b2087c759d2345a0cc30ac37c3f15760024ab19fdf83106f7cdefb87fa83c025100fd36852931f60c82f
7
+ data.tar.gz: 7a49e748bfc08d1c918c494f16d8ec4f579e63b3ec97120b532d168ffc9865e1748c9113025c0dfe7c9505c0f1aa3830624299e16926bc1b60be52187a71b0a5
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.1.8] - 2026-03-31
4
+
5
+ ### Added
6
+ - Attachment sub-module for Phase C bond modeling
7
+ - `AttachmentModel`: per-agent attachment strength, style, and stage tracking with EMA updates and no-regression stage transitions
8
+ - `AttachmentStore`: in-memory store with dirty tracking and Apollo Local persistence (`to_apollo_entries`, `from_apollo`)
9
+ - `Attachment` runner: `update_attachment` (tick integration), `reflect_on_bonds` (dream cycle orchestrator reading cross-module bond data), `attachment_stats`
10
+
3
11
  ## [0.1.7] - 2026-03-31
4
12
 
5
13
  ### Changed
data/Gemfile CHANGED
@@ -3,3 +3,5 @@
3
3
  source 'https://rubygems.org'
4
4
 
5
5
  gemspec
6
+
7
+ gem 'rubocop-legion'
@@ -33,7 +33,7 @@ Gem::Specification.new do |spec|
33
33
  spec.add_dependency 'legion-transport', '>= 1.3.9'
34
34
 
35
35
  spec.add_development_dependency 'rspec', '~> 3.13'
36
- spec.add_development_dependency 'rubocop', '~> 1.60'
37
- spec.add_development_dependency 'rubocop-legion', '~> 0.1'
38
- spec.add_development_dependency 'rubocop-rspec', '~> 2.26'
36
+ spec.add_development_dependency 'rubocop'
37
+ spec.add_development_dependency 'rubocop-legion'
38
+ spec.add_development_dependency 'rubocop-rspec'
39
39
  end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Agentic
6
+ module Social
7
+ module Attachment
8
+ class Client
9
+ include Runners::Attachment
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Agentic
6
+ module Social
7
+ module Attachment
8
+ module Helpers
9
+ class AttachmentModel
10
+ attr_reader :agent_id, :attachment_strength, :attachment_style,
11
+ :bond_stage, :separation_tolerance, :interaction_count
12
+
13
+ def initialize(agent_id:)
14
+ @agent_id = agent_id
15
+ @attachment_strength = 0.0
16
+ @attachment_style = :secure
17
+ @bond_stage = :initial
18
+ @separation_tolerance = Constants::BASE_SEPARATION_TOLERANCE
19
+ @interaction_count = 0
20
+ end
21
+
22
+ def update_from_signals(opts = {})
23
+ frequency_score = opts.fetch(:frequency_score, 0.0)
24
+ reciprocity_score = opts.fetch(:reciprocity_score, 0.0)
25
+ prediction_accuracy = opts.fetch(:prediction_accuracy, 0.0)
26
+ direct_address_ratio = opts.fetch(:direct_address_ratio, 0.0)
27
+ channel_consistency = opts.fetch(:channel_consistency, 0.0)
28
+
29
+ raw = (frequency_score * Constants::FREQUENCY_WEIGHT) +
30
+ (reciprocity_score * Constants::RECIPROCITY_WEIGHT) +
31
+ (prediction_accuracy * Constants::PREDICTION_ACCURACY_WEIGHT) +
32
+ (direct_address_ratio * Constants::DIRECT_ADDRESS_WEIGHT) +
33
+ (channel_consistency * Constants::CHANNEL_CONSISTENCY_WEIGHT)
34
+
35
+ @attachment_strength = if @interaction_count.zero?
36
+ raw.clamp(0.0, 1.0)
37
+ else
38
+ alpha = Constants::STRENGTH_ALPHA
39
+ ((alpha * raw) + ((1.0 - alpha) * @attachment_strength)).clamp(0.0, 1.0)
40
+ end
41
+ @interaction_count += 1
42
+ end
43
+
44
+ def update_stage!
45
+ new_stage = derive_stage
46
+ return if Constants::BOND_STAGES.index(new_stage) <= Constants::BOND_STAGES.index(@bond_stage)
47
+
48
+ @bond_stage = new_stage
49
+ @separation_tolerance = Constants::BASE_SEPARATION_TOLERANCE +
50
+ Constants::SEPARATION_TOLERANCE_GROWTH.fetch(@bond_stage, 0)
51
+ end
52
+
53
+ def derive_style!(opts = {})
54
+ frequency_variance = opts.fetch(:frequency_variance, 0.0)
55
+ reciprocity_imbalance = opts.fetch(:reciprocity_imbalance, 0.0)
56
+ frequency = opts.fetch(:frequency, 0.0)
57
+ direct_address_ratio = opts.fetch(:direct_address_ratio, 0.0)
58
+ thresholds = Constants::STYLE_THRESHOLDS
59
+ @attachment_style = if frequency_variance > thresholds[:anxious_frequency_variance] &&
60
+ reciprocity_imbalance > thresholds[:anxious_reciprocity_imbalance]
61
+ :anxious
62
+ elsif frequency < thresholds[:avoidant_frequency] &&
63
+ direct_address_ratio < thresholds[:avoidant_direct_address]
64
+ :avoidant
65
+ else
66
+ :secure
67
+ end
68
+ end
69
+
70
+ def to_h
71
+ { agent_id: @agent_id, attachment_strength: @attachment_strength,
72
+ attachment_style: @attachment_style, bond_stage: @bond_stage,
73
+ separation_tolerance: @separation_tolerance, interaction_count: @interaction_count }
74
+ end
75
+
76
+ def self.from_h(hash)
77
+ model = new(agent_id: hash[:agent_id])
78
+ model.instance_variable_set(:@attachment_strength, hash[:attachment_strength].to_f)
79
+ model.instance_variable_set(:@attachment_style, hash[:attachment_style]&.to_sym || :secure)
80
+ model.instance_variable_set(:@bond_stage, hash[:bond_stage]&.to_sym || :initial)
81
+ model.instance_variable_set(:@separation_tolerance, hash[:separation_tolerance]&.to_i || 3)
82
+ model.instance_variable_set(:@interaction_count, hash[:interaction_count].to_i)
83
+ model
84
+ end
85
+
86
+ private
87
+
88
+ def derive_stage
89
+ Constants::STAGE_THRESHOLDS.each_key.reverse_each do |stage|
90
+ threshold = Constants::STAGE_THRESHOLDS[stage]
91
+ return stage if @interaction_count >= threshold[:interactions] &&
92
+ @attachment_strength >= threshold[:strength]
93
+ end
94
+ :initial
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Agentic
6
+ module Social
7
+ module Attachment
8
+ module Helpers
9
+ class AttachmentStore
10
+ def initialize
11
+ @models = {}
12
+ @dirty = false
13
+ end
14
+
15
+ def get(agent_id)
16
+ @models[agent_id.to_s]
17
+ end
18
+
19
+ def get_or_create(agent_id)
20
+ key = agent_id.to_s
21
+ @models[key] ||= begin
22
+ @dirty = true
23
+ AttachmentModel.new(agent_id: key)
24
+ end
25
+ end
26
+
27
+ def all_models
28
+ @models.values
29
+ end
30
+
31
+ def dirty?
32
+ @dirty
33
+ end
34
+
35
+ def mark_clean!
36
+ @dirty = false
37
+ self
38
+ end
39
+
40
+ def to_apollo_entries
41
+ @models.map do |agent_id, model|
42
+ tags = Constants::TAG_PREFIX.dup + [agent_id]
43
+ tags << 'partner' if partner?(agent_id)
44
+ content = serialize(model.to_h)
45
+ { content: content, tags: tags }
46
+ end
47
+ end
48
+
49
+ def from_apollo(store:)
50
+ result = store.query(text: 'bond attachment', tags: %w[bond attachment])
51
+ return false unless result[:success] && result[:results]&.any?
52
+
53
+ result[:results].each do |entry|
54
+ parsed = deserialize(entry[:content])
55
+ next unless parsed && parsed[:agent_id]
56
+
57
+ @models[parsed[:agent_id].to_s] = AttachmentModel.from_h(parsed)
58
+ end
59
+ true
60
+ rescue StandardError => e
61
+ warn "[attachment_store] from_apollo error: #{e.message}"
62
+ false
63
+ end
64
+
65
+ private
66
+
67
+ def partner?(agent_id)
68
+ defined?(Legion::Gaia::BondRegistry) && Legion::Gaia::BondRegistry.partner?(agent_id)
69
+ end
70
+
71
+ def serialize(hash)
72
+ defined?(Legion::JSON) ? Legion::JSON.dump(hash) : ::JSON.dump(hash)
73
+ end
74
+
75
+ def deserialize(content)
76
+ parsed = defined?(Legion::JSON) ? Legion::JSON.parse(content) : ::JSON.parse(content, symbolize_names: true)
77
+ parsed.transform_keys(&:to_sym)
78
+ rescue StandardError => _e
79
+ nil
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Agentic
6
+ module Social
7
+ module Attachment
8
+ module Helpers
9
+ module Constants
10
+ # Strength computation weights (must sum to 1.0)
11
+ FREQUENCY_WEIGHT = 0.3
12
+ RECIPROCITY_WEIGHT = 0.25
13
+ PREDICTION_ACCURACY_WEIGHT = 0.2
14
+ DIRECT_ADDRESS_WEIGHT = 0.15
15
+ CHANNEL_CONSISTENCY_WEIGHT = 0.1
16
+
17
+ # Bond lifecycle stages
18
+ BOND_STAGES = %i[initial forming established deep].freeze
19
+
20
+ # Attachment style classifications
21
+ ATTACHMENT_STYLES = %i[secure anxious avoidant].freeze
22
+
23
+ # Stage progression thresholds
24
+ STAGE_THRESHOLDS = {
25
+ forming: { interactions: 10, strength: 0.3 },
26
+ established: { interactions: 50, strength: 0.5 },
27
+ deep: { interactions: 200, strength: 0.7 }
28
+ }.freeze
29
+
30
+ # Separation tolerance (consecutive prediction misses before anxiety signal)
31
+ BASE_SEPARATION_TOLERANCE = 3
32
+ SEPARATION_TOLERANCE_GROWTH = {
33
+ initial: 0, forming: 1, established: 2, deep: 4
34
+ }.freeze
35
+
36
+ # EMA alpha for strength updates
37
+ STRENGTH_ALPHA = 0.15
38
+
39
+ # Style derivation thresholds
40
+ STYLE_THRESHOLDS = {
41
+ anxious_frequency_variance: 0.4,
42
+ anxious_reciprocity_imbalance: 0.3,
43
+ avoidant_frequency: 0.2,
44
+ avoidant_direct_address: 0.15
45
+ }.freeze
46
+
47
+ # Apollo Local tag prefix
48
+ TAG_PREFIX = %w[bond attachment].freeze
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Agentic
6
+ module Social
7
+ module Attachment
8
+ module Runners
9
+ module Attachment
10
+ include Legion::Extensions::Helpers::Lex if defined?(Legion::Extensions::Helpers) &&
11
+ Legion::Extensions::Helpers.const_defined?(:Lex, false)
12
+
13
+ def update_attachment(tick_results: {}, human_observations: [], **)
14
+ agents = collect_agent_ids(tick_results, human_observations)
15
+ return { agents_updated: 0 } if agents.empty?
16
+
17
+ agents.each do |agent_id|
18
+ model = attachment_store.get_or_create(agent_id)
19
+ signals = extract_signals(agent_id, tick_results, human_observations)
20
+ model.update_from_signals(signals)
21
+ model.update_stage!
22
+ model.derive_style!(extract_style_signals(agent_id, human_observations))
23
+ end
24
+
25
+ { agents_updated: agents.size, models: agents.map { |id| attachment_store.get(id)&.to_h } }
26
+ end
27
+
28
+ def reflect_on_bonds(_tick_results: {}, _bond_summary: {}, **)
29
+ store = apollo_local_store
30
+ return { success: false, error: :no_store } unless store
31
+
32
+ partner_id = resolve_partner_id
33
+ model = attachment_store.get(partner_id) if partner_id
34
+ comm_patterns = read_communication_patterns(store, partner_id)
35
+ arc_state = read_relationship_arc(store, partner_id)
36
+ health = compute_relationship_health(model, comm_patterns, arc_state)
37
+
38
+ {
39
+ bonds_reflected: attachment_store.all_models.size,
40
+ partner_bond: if model
41
+ {
42
+ stage: model.bond_stage,
43
+ strength: model.attachment_strength,
44
+ style: model.attachment_style,
45
+ health: health,
46
+ milestones_today: arc_state[:milestones_today] || [],
47
+ narrative: nil
48
+ }
49
+ end
50
+ }
51
+ rescue StandardError => e
52
+ { success: false, error: e.message }
53
+ end
54
+
55
+ def attachment_stats(**)
56
+ partner_id = resolve_partner_id
57
+ partner_model = attachment_store.get(partner_id) if partner_id
58
+ {
59
+ bonds_tracked: attachment_store.all_models.size,
60
+ partner_bond: partner_model&.to_h
61
+ }
62
+ end
63
+
64
+ private
65
+
66
+ def attachment_store
67
+ @attachment_store ||= Helpers::AttachmentStore.new
68
+ end
69
+
70
+ def collect_agent_ids(tick_results, human_observations)
71
+ ids = Set.new
72
+ (tick_results.dig(:social_cognition, :reputation_updates) || []).each do |u|
73
+ ids << u[:agent_id].to_s if u[:agent_id]
74
+ end
75
+ human_observations.each { |o| ids << o[:agent_id].to_s if o[:agent_id] }
76
+ ids.to_a
77
+ end
78
+
79
+ def extract_signals(agent_id, tick_results, human_observations)
80
+ reputation = (tick_results.dig(:social_cognition, :reputation_updates) || [])
81
+ .find { |u| u[:agent_id].to_s == agent_id }
82
+ prediction = tick_results.dig(:theory_of_mind, :prediction_accuracy) || {}
83
+ obs = human_observations.select { |o| o[:agent_id].to_s == agent_id }
84
+
85
+ direct_count = obs.count { |o| o[:direct_address] }
86
+ channels = obs.filter_map { |o| o[:channel] }.uniq
87
+
88
+ {
89
+ frequency_score: obs.size.clamp(0, 10) / 10.0,
90
+ reciprocity_score: (reputation&.dig(:composite) || 0.0).clamp(0.0, 1.0),
91
+ prediction_accuracy: (prediction[agent_id] || 0.0).clamp(0.0, 1.0),
92
+ direct_address_ratio: obs.empty? ? 0.0 : (direct_count.to_f / obs.size).clamp(0.0, 1.0),
93
+ channel_consistency: channels.size <= 1 ? 1.0 : (1.0 / channels.size).clamp(0.0, 1.0)
94
+ }
95
+ end
96
+
97
+ def extract_style_signals(agent_id, human_observations)
98
+ obs = human_observations.select { |o| o[:agent_id].to_s == agent_id }
99
+ direct_count = obs.count { |o| o[:direct_address] }
100
+ {
101
+ frequency_variance: 0.0,
102
+ reciprocity_imbalance: 0.0,
103
+ frequency: obs.size.clamp(0, 10) / 10.0,
104
+ direct_address_ratio: obs.empty? ? 0.0 : direct_count.to_f / obs.size
105
+ }
106
+ end
107
+
108
+ def resolve_partner_id
109
+ if defined?(Legion::Gaia::BondRegistry)
110
+ bond = Legion::Gaia::BondRegistry.all_bonds.find { |b| b[:role] == :partner }
111
+ return bond&.dig(:identity)&.to_s
112
+ end
113
+
114
+ strongest = attachment_store.all_models.max_by(&:attachment_strength)
115
+ strongest&.agent_id
116
+ end
117
+
118
+ def apollo_local_store
119
+ return nil unless defined?(Legion::Apollo::Local) && Legion::Apollo::Local.started?
120
+
121
+ Legion::Apollo::Local
122
+ end
123
+
124
+ def read_communication_patterns(store, partner_id)
125
+ return {} unless partner_id
126
+
127
+ result = store.query(text: 'communication_pattern',
128
+ tags: ['bond', 'communication_pattern', partner_id])
129
+ return {} unless result[:success] && result[:results]&.any?
130
+
131
+ deserialize(result[:results].first[:content]) || {}
132
+ rescue StandardError => _e
133
+ {}
134
+ end
135
+
136
+ def read_relationship_arc(store, partner_id)
137
+ return {} unless partner_id
138
+
139
+ result = store.query(text: 'relationship_arc',
140
+ tags: ['bond', 'relationship_arc', partner_id])
141
+ return {} unless result[:success] && result[:results]&.any?
142
+
143
+ deserialize(result[:results].first[:content]) || {}
144
+ rescue StandardError => _e
145
+ {}
146
+ end
147
+
148
+ def compute_relationship_health(model, comm_patterns, arc_state)
149
+ return 0.0 unless model
150
+
151
+ strength_component = model.attachment_strength * 0.4
152
+ reciprocity_component = (arc_state[:reciprocity_balance] || 0.5) * 0.3
153
+ consistency_component = (comm_patterns[:consistency] || 0.5) * 0.3
154
+ (strength_component + reciprocity_component + consistency_component).clamp(0.0, 1.0)
155
+ end
156
+
157
+ def deserialize(content)
158
+ parsed = defined?(Legion::JSON) ? Legion::JSON.parse(content) : ::JSON.parse(content, symbolize_names: true)
159
+ parsed.is_a?(Hash) ? parsed.transform_keys(&:to_sym) : {}
160
+ rescue StandardError => _e
161
+ {}
162
+ end
163
+ end
164
+ end
165
+ end
166
+ end
167
+ end
168
+ end
169
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Agentic
6
+ module Social
7
+ module Attachment
8
+ VERSION = '0.1.0'
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'attachment/version'
4
+ require_relative 'attachment/helpers/constants'
5
+ require_relative 'attachment/helpers/attachment_model'
6
+ require_relative 'attachment/helpers/attachment_store'
7
+ require_relative 'attachment/runners/attachment'
8
+ require_relative 'attachment/client'
@@ -4,7 +4,7 @@ module Legion
4
4
  module Extensions
5
5
  module Agentic
6
6
  module Social
7
- VERSION = '0.1.7'
7
+ VERSION = '0.1.8'
8
8
  end
9
9
  end
10
10
  end
@@ -18,6 +18,7 @@ require_relative 'social/moral_reasoning'
18
18
  require_relative 'social/governance'
19
19
  require_relative 'social/joint_attention'
20
20
  require_relative 'social/mirror_system'
21
+ require_relative 'social/attachment'
21
22
 
22
23
  module Legion
23
24
  module Extensions
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'legion/extensions/agentic/social/attachment/helpers/constants'
5
+ require 'legion/extensions/agentic/social/attachment/helpers/attachment_model'
6
+
7
+ RSpec.describe Legion::Extensions::Agentic::Social::Attachment::Helpers::AttachmentModel do
8
+ subject(:model) { described_class.new(agent_id: 'partner-1') }
9
+
10
+ describe '#initialize' do
11
+ it 'sets agent_id' do
12
+ expect(model.agent_id).to eq('partner-1')
13
+ end
14
+
15
+ it 'starts at initial stage' do
16
+ expect(model.bond_stage).to eq(:initial)
17
+ end
18
+
19
+ it 'starts with zero strength' do
20
+ expect(model.attachment_strength).to eq(0.0)
21
+ end
22
+
23
+ it 'starts with secure style' do
24
+ expect(model.attachment_style).to eq(:secure)
25
+ end
26
+
27
+ it 'starts with base separation tolerance' do
28
+ expect(model.separation_tolerance).to eq(3)
29
+ end
30
+
31
+ it 'starts with zero interaction count' do
32
+ expect(model.interaction_count).to eq(0)
33
+ end
34
+ end
35
+
36
+ describe '#update_from_signals' do
37
+ let(:signals) do
38
+ {
39
+ frequency_score: 0.6,
40
+ reciprocity_score: 0.5,
41
+ prediction_accuracy: 0.7,
42
+ direct_address_ratio: 0.4,
43
+ channel_consistency: 0.8
44
+ }
45
+ end
46
+
47
+ it 'computes attachment strength from weighted signals' do
48
+ model.update_from_signals(signals)
49
+ expected = (0.6 * 0.3) + (0.5 * 0.25) + (0.7 * 0.2) + (0.4 * 0.15) + (0.8 * 0.1)
50
+ expect(model.attachment_strength).to be_within(0.01).of(expected)
51
+ end
52
+
53
+ it 'increments interaction count' do
54
+ model.update_from_signals(signals)
55
+ expect(model.interaction_count).to eq(1)
56
+ end
57
+
58
+ it 'uses EMA for subsequent updates' do
59
+ model.update_from_signals(signals)
60
+ first_strength = model.attachment_strength
61
+ model.update_from_signals(signals.merge(frequency_score: 1.0))
62
+ expect(model.attachment_strength).not_to eq(first_strength)
63
+ end
64
+ end
65
+
66
+ describe '#update_stage!' do
67
+ it 'transitions to forming at 10 interactions and strength > 0.3' do
68
+ model.instance_variable_set(:@interaction_count, 10)
69
+ model.instance_variable_set(:@attachment_strength, 0.35)
70
+ model.update_stage!
71
+ expect(model.bond_stage).to eq(:forming)
72
+ end
73
+
74
+ it 'transitions to established at 50 interactions and strength > 0.5' do
75
+ model.instance_variable_set(:@interaction_count, 50)
76
+ model.instance_variable_set(:@attachment_strength, 0.55)
77
+ model.instance_variable_set(:@bond_stage, :forming)
78
+ model.update_stage!
79
+ expect(model.bond_stage).to eq(:established)
80
+ end
81
+
82
+ it 'transitions to deep at 200 interactions and strength > 0.7' do
83
+ model.instance_variable_set(:@interaction_count, 200)
84
+ model.instance_variable_set(:@attachment_strength, 0.75)
85
+ model.instance_variable_set(:@bond_stage, :established)
86
+ model.update_stage!
87
+ expect(model.bond_stage).to eq(:deep)
88
+ end
89
+
90
+ it 'never regresses stages' do
91
+ model.instance_variable_set(:@bond_stage, :established)
92
+ model.instance_variable_set(:@interaction_count, 5)
93
+ model.instance_variable_set(:@attachment_strength, 0.1)
94
+ model.update_stage!
95
+ expect(model.bond_stage).to eq(:established)
96
+ end
97
+
98
+ it 'updates separation tolerance on stage change' do
99
+ model.instance_variable_set(:@interaction_count, 50)
100
+ model.instance_variable_set(:@attachment_strength, 0.55)
101
+ model.instance_variable_set(:@bond_stage, :forming)
102
+ model.update_stage!
103
+ expect(model.separation_tolerance).to eq(3 + 2)
104
+ end
105
+ end
106
+
107
+ describe '#derive_style!' do
108
+ it 'defaults to secure' do
109
+ model.derive_style!(frequency_variance: 0.1, reciprocity_imbalance: 0.1,
110
+ frequency: 0.5, direct_address_ratio: 0.5)
111
+ expect(model.attachment_style).to eq(:secure)
112
+ end
113
+
114
+ it 'detects anxious from high variance and imbalance' do
115
+ model.derive_style!(frequency_variance: 0.5, reciprocity_imbalance: 0.4,
116
+ frequency: 0.5, direct_address_ratio: 0.5)
117
+ expect(model.attachment_style).to eq(:anxious)
118
+ end
119
+
120
+ it 'detects avoidant from low frequency and low direct address' do
121
+ model.derive_style!(frequency_variance: 0.1, reciprocity_imbalance: 0.1,
122
+ frequency: 0.1, direct_address_ratio: 0.1)
123
+ expect(model.attachment_style).to eq(:avoidant)
124
+ end
125
+ end
126
+
127
+ describe '#to_h' do
128
+ it 'returns a complete hash' do
129
+ h = model.to_h
130
+ expect(h).to include(:agent_id, :attachment_strength, :attachment_style,
131
+ :bond_stage, :separation_tolerance, :interaction_count)
132
+ end
133
+ end
134
+
135
+ describe 'serialization' do
136
+ it 'round-trips through from_h' do
137
+ model.instance_variable_set(:@attachment_strength, 0.65)
138
+ model.instance_variable_set(:@bond_stage, :established)
139
+ model.instance_variable_set(:@interaction_count, 55)
140
+ restored = described_class.from_h(model.to_h)
141
+ expect(restored.to_h).to eq(model.to_h)
142
+ end
143
+ end
144
+ end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'legion/extensions/agentic/social/attachment/helpers/constants'
5
+ require 'legion/extensions/agentic/social/attachment/helpers/attachment_model'
6
+ require 'legion/extensions/agentic/social/attachment/helpers/attachment_store'
7
+
8
+ RSpec.describe Legion::Extensions::Agentic::Social::Attachment::Helpers::AttachmentStore do
9
+ subject(:store) { described_class.new }
10
+
11
+ describe '#get' do
12
+ it 'returns nil for unknown agent' do
13
+ expect(store.get('unknown')).to be_nil
14
+ end
15
+ end
16
+
17
+ describe '#get_or_create' do
18
+ it 'creates a new model for unknown agent' do
19
+ model = store.get_or_create('agent-1')
20
+ expect(model).to be_a(Legion::Extensions::Agentic::Social::Attachment::Helpers::AttachmentModel)
21
+ expect(model.agent_id).to eq('agent-1')
22
+ end
23
+
24
+ it 'returns existing model on second call' do
25
+ first = store.get_or_create('agent-1')
26
+ second = store.get_or_create('agent-1')
27
+ expect(first).to equal(second)
28
+ end
29
+ end
30
+
31
+ describe '#dirty?' do
32
+ it 'starts clean' do
33
+ expect(store).not_to be_dirty
34
+ end
35
+
36
+ it 'becomes dirty after get_or_create' do
37
+ store.get_or_create('agent-1')
38
+ expect(store).to be_dirty
39
+ end
40
+ end
41
+
42
+ describe '#mark_clean!' do
43
+ it 'clears dirty flag' do
44
+ store.get_or_create('agent-1')
45
+ store.mark_clean!
46
+ expect(store).not_to be_dirty
47
+ end
48
+
49
+ it 'returns self for chaining' do
50
+ expect(store.mark_clean!).to eq(store)
51
+ end
52
+ end
53
+
54
+ describe '#to_apollo_entries' do
55
+ before { store.get_or_create('partner-1') }
56
+
57
+ it 'returns an array of entry hashes' do
58
+ entries = store.to_apollo_entries
59
+ expect(entries).to be_an(Array)
60
+ expect(entries.size).to eq(1)
61
+ end
62
+
63
+ it 'includes content and tags' do
64
+ entry = store.to_apollo_entries.first
65
+ expect(entry).to have_key(:content)
66
+ expect(entry).to have_key(:tags)
67
+ end
68
+
69
+ it 'tags with bond, attachment, and agent_id' do
70
+ entry = store.to_apollo_entries.first
71
+ expect(entry[:tags]).to include('bond', 'attachment', 'partner-1')
72
+ end
73
+
74
+ it 'adds partner tag when BondRegistry says partner' do
75
+ bond_registry = class_double('Legion::Gaia::BondRegistry')
76
+ stub_const('Legion::Gaia::BondRegistry', bond_registry)
77
+ allow(bond_registry).to receive(:partner?).with('partner-1').and_return(true)
78
+ entry = store.to_apollo_entries.first
79
+ expect(entry[:tags]).to include('partner')
80
+ end
81
+ end
82
+
83
+ describe '#from_apollo' do
84
+ let(:mock_store) { double('apollo_local') }
85
+
86
+ it 'restores models from query results' do
87
+ model = Legion::Extensions::Agentic::Social::Attachment::Helpers::AttachmentModel.new(agent_id: 'p1')
88
+ model.instance_variable_set(:@attachment_strength, 0.6)
89
+ model.instance_variable_set(:@bond_stage, :established)
90
+ content = Legion::JSON.dump(model.to_h) if defined?(Legion::JSON)
91
+ content ||= JSON.dump(model.to_h)
92
+
93
+ allow(mock_store).to receive(:query)
94
+ .with(text: 'bond attachment', tags: %w[bond attachment])
95
+ .and_return({ success: true, results: [{ content: content, tags: %w[bond attachment p1] }] })
96
+
97
+ result = store.from_apollo(store: mock_store)
98
+ expect(result).to be true
99
+ expect(store.get('p1')).not_to be_nil
100
+ expect(store.get('p1').attachment_strength).to eq(0.6)
101
+ end
102
+
103
+ it 'returns false on empty results' do
104
+ allow(mock_store).to receive(:query).and_return({ success: true, results: [] })
105
+ expect(store.from_apollo(store: mock_store)).to be false
106
+ end
107
+
108
+ it 'returns false on error' do
109
+ allow(mock_store).to receive(:query).and_raise(StandardError, 'db error')
110
+ expect(store.from_apollo(store: mock_store)).to be false
111
+ end
112
+ end
113
+
114
+ describe '#all_models' do
115
+ it 'returns all tracked models' do
116
+ store.get_or_create('a1')
117
+ store.get_or_create('a2')
118
+ expect(store.all_models.size).to eq(2)
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'legion/extensions/agentic/social/attachment/helpers/constants'
5
+
6
+ RSpec.describe Legion::Extensions::Agentic::Social::Attachment::Helpers::Constants do
7
+ describe 'strength weights' do
8
+ it 'sums to 1.0' do
9
+ sum = described_class::FREQUENCY_WEIGHT +
10
+ described_class::RECIPROCITY_WEIGHT +
11
+ described_class::PREDICTION_ACCURACY_WEIGHT +
12
+ described_class::DIRECT_ADDRESS_WEIGHT +
13
+ described_class::CHANNEL_CONSISTENCY_WEIGHT
14
+ expect(sum).to eq(1.0)
15
+ end
16
+ end
17
+
18
+ describe 'BOND_STAGES' do
19
+ it 'defines 4 stages in order' do
20
+ expect(described_class::BOND_STAGES).to eq(%i[initial forming established deep])
21
+ end
22
+ end
23
+
24
+ describe 'ATTACHMENT_STYLES' do
25
+ it 'defines 3 styles' do
26
+ expect(described_class::ATTACHMENT_STYLES).to eq(%i[secure anxious avoidant])
27
+ end
28
+ end
29
+
30
+ describe 'stage thresholds' do
31
+ it 'has forming thresholds' do
32
+ expect(described_class::STAGE_THRESHOLDS[:forming]).to eq({ interactions: 10, strength: 0.3 })
33
+ end
34
+
35
+ it 'has established thresholds' do
36
+ expect(described_class::STAGE_THRESHOLDS[:established]).to eq({ interactions: 50, strength: 0.5 })
37
+ end
38
+
39
+ it 'has deep thresholds' do
40
+ expect(described_class::STAGE_THRESHOLDS[:deep]).to eq({ interactions: 200, strength: 0.7 })
41
+ end
42
+ end
43
+
44
+ describe 'SEPARATION_TOLERANCE' do
45
+ it 'starts at 3' do
46
+ expect(described_class::BASE_SEPARATION_TOLERANCE).to eq(3)
47
+ end
48
+
49
+ it 'grows with stage' do
50
+ expect(described_class::SEPARATION_TOLERANCE_GROWTH[:deep]).to be > described_class::SEPARATION_TOLERANCE_GROWTH[:initial]
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,130 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'legion/extensions/agentic/social/attachment/helpers/constants'
5
+ require 'legion/extensions/agentic/social/attachment/helpers/attachment_model'
6
+ require 'legion/extensions/agentic/social/attachment/helpers/attachment_store'
7
+ require 'legion/extensions/agentic/social/attachment/runners/attachment'
8
+
9
+ RSpec.describe Legion::Extensions::Agentic::Social::Attachment::Runners::Attachment do
10
+ let(:host) { Object.new.extend(described_class) }
11
+
12
+ before do
13
+ # Reset memoized store between specs
14
+ host.instance_variable_set(:@attachment_store, nil)
15
+ end
16
+
17
+ describe '#update_attachment' do
18
+ let(:tick_results) do
19
+ {
20
+ social_cognition: {
21
+ reputation_updates: [
22
+ { agent_id: 'partner-1', composite: 0.6 }
23
+ ],
24
+ reciprocity_ledger_size: 10
25
+ },
26
+ theory_of_mind: {
27
+ prediction_accuracy: { 'partner-1' => 0.7 }
28
+ }
29
+ }
30
+ end
31
+
32
+ let(:human_observations) do
33
+ [
34
+ { agent_id: 'partner-1', direct_address: true, channel: 'teams' },
35
+ { agent_id: 'partner-1', direct_address: false, channel: 'teams' },
36
+ { agent_id: 'partner-1', direct_address: true, channel: 'cli' }
37
+ ]
38
+ end
39
+
40
+ it 'returns a result hash' do
41
+ result = host.update_attachment(tick_results: tick_results, human_observations: human_observations)
42
+ expect(result).to be_a(Hash)
43
+ expect(result).to have_key(:agents_updated)
44
+ end
45
+
46
+ it 'creates a model for observed agents' do
47
+ host.update_attachment(tick_results: tick_results, human_observations: human_observations)
48
+ store = host.send(:attachment_store)
49
+ expect(store.get('partner-1')).not_to be_nil
50
+ end
51
+
52
+ it 'updates attachment strength' do
53
+ host.update_attachment(tick_results: tick_results, human_observations: human_observations)
54
+ model = host.send(:attachment_store).get('partner-1')
55
+ expect(model.attachment_strength).to be > 0.0
56
+ end
57
+
58
+ it 'marks store dirty' do
59
+ host.update_attachment(tick_results: tick_results, human_observations: human_observations)
60
+ expect(host.send(:attachment_store)).to be_dirty
61
+ end
62
+
63
+ it 'handles empty tick results' do
64
+ result = host.update_attachment(tick_results: {}, human_observations: [])
65
+ expect(result[:agents_updated]).to eq(0)
66
+ end
67
+ end
68
+
69
+ describe '#reflect_on_bonds' do
70
+ let(:mock_store) { double('apollo_local') }
71
+
72
+ before do
73
+ allow(host).to receive(:apollo_local_store).and_return(mock_store)
74
+ allow(mock_store).to receive(:query).and_return({ success: true, results: [] })
75
+
76
+ store = host.send(:attachment_store)
77
+ model = store.get_or_create('partner-1')
78
+ model.update_from_signals(frequency_score: 0.6, reciprocity_score: 0.5,
79
+ prediction_accuracy: 0.7, direct_address_ratio: 0.4,
80
+ channel_consistency: 0.8)
81
+ model.instance_variable_set(:@interaction_count, 55)
82
+ model.instance_variable_set(:@bond_stage, :established)
83
+ end
84
+
85
+ it 'returns bond reflection result' do
86
+ result = host.reflect_on_bonds(tick_results: {}, bond_summary: {})
87
+ expect(result).to have_key(:bonds_reflected)
88
+ expect(result).to have_key(:partner_bond)
89
+ end
90
+
91
+ it 'includes partner bond state' do
92
+ result = host.reflect_on_bonds(tick_results: {}, bond_summary: {})
93
+ bond = result[:partner_bond]
94
+ expect(bond[:stage]).to eq(:established)
95
+ expect(bond[:strength]).to be > 0.0
96
+ end
97
+
98
+ it 'reads communication patterns from Apollo Local' do
99
+ host.reflect_on_bonds(tick_results: {}, bond_summary: {})
100
+ expect(mock_store).to have_received(:query)
101
+ .with(hash_including(tags: array_including('bond', 'communication_pattern')))
102
+ end
103
+
104
+ it 'reads relationship arc from Apollo Local' do
105
+ host.reflect_on_bonds(tick_results: {}, bond_summary: {})
106
+ expect(mock_store).to have_received(:query)
107
+ .with(hash_including(tags: array_including('bond', 'relationship_arc')))
108
+ end
109
+
110
+ it 'returns error when no store available' do
111
+ allow(host).to receive(:apollo_local_store).and_return(nil)
112
+ result = host.reflect_on_bonds(tick_results: {}, bond_summary: {})
113
+ expect(result[:success]).to be false
114
+ end
115
+
116
+ it 'computes relationship health' do
117
+ result = host.reflect_on_bonds(tick_results: {}, bond_summary: {})
118
+ expect(result[:partner_bond][:health]).to be_a(Float)
119
+ expect(result[:partner_bond][:health]).to be_between(0.0, 1.0)
120
+ end
121
+ end
122
+
123
+ describe '#attachment_stats' do
124
+ it 'returns stats hash' do
125
+ result = host.attachment_stats
126
+ expect(result).to have_key(:bonds_tracked)
127
+ expect(result).to have_key(:partner_bond)
128
+ end
129
+ end
130
+ 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.7
4
+ version: 0.1.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -125,44 +125,44 @@ dependencies:
125
125
  name: rubocop
126
126
  requirement: !ruby/object:Gem::Requirement
127
127
  requirements:
128
- - - "~>"
128
+ - - ">="
129
129
  - !ruby/object:Gem::Version
130
- version: '1.60'
130
+ version: '0'
131
131
  type: :development
132
132
  prerelease: false
133
133
  version_requirements: !ruby/object:Gem::Requirement
134
134
  requirements:
135
- - - "~>"
135
+ - - ">="
136
136
  - !ruby/object:Gem::Version
137
- version: '1.60'
137
+ version: '0'
138
138
  - !ruby/object:Gem::Dependency
139
139
  name: rubocop-legion
140
140
  requirement: !ruby/object:Gem::Requirement
141
141
  requirements:
142
- - - "~>"
142
+ - - ">="
143
143
  - !ruby/object:Gem::Version
144
- version: '0.1'
144
+ version: '0'
145
145
  type: :development
146
146
  prerelease: false
147
147
  version_requirements: !ruby/object:Gem::Requirement
148
148
  requirements:
149
- - - "~>"
149
+ - - ">="
150
150
  - !ruby/object:Gem::Version
151
- version: '0.1'
151
+ version: '0'
152
152
  - !ruby/object:Gem::Dependency
153
153
  name: rubocop-rspec
154
154
  requirement: !ruby/object:Gem::Requirement
155
155
  requirements:
156
- - - "~>"
156
+ - - ">="
157
157
  - !ruby/object:Gem::Version
158
- version: '2.26'
158
+ version: '0'
159
159
  type: :development
160
160
  prerelease: false
161
161
  version_requirements: !ruby/object:Gem::Requirement
162
162
  requirements:
163
- - - "~>"
163
+ - - ">="
164
164
  - !ruby/object:Gem::Version
165
- version: '2.26'
165
+ version: '0'
166
166
  description: 'LEX agentic social domain: empathy, social cognition, theory of mind'
167
167
  email:
168
168
  - matthewdiverson@gmail.com
@@ -183,6 +183,13 @@ files:
183
183
  - lib/legion/extensions/agentic/social/apprenticeship/helpers/apprenticeship_model.rb
184
184
  - lib/legion/extensions/agentic/social/apprenticeship/runners/cognitive_apprenticeship.rb
185
185
  - lib/legion/extensions/agentic/social/apprenticeship/version.rb
186
+ - lib/legion/extensions/agentic/social/attachment.rb
187
+ - lib/legion/extensions/agentic/social/attachment/client.rb
188
+ - lib/legion/extensions/agentic/social/attachment/helpers/attachment_model.rb
189
+ - lib/legion/extensions/agentic/social/attachment/helpers/attachment_store.rb
190
+ - lib/legion/extensions/agentic/social/attachment/helpers/constants.rb
191
+ - lib/legion/extensions/agentic/social/attachment/runners/attachment.rb
192
+ - lib/legion/extensions/agentic/social/attachment/version.rb
186
193
  - lib/legion/extensions/agentic/social/conflict.rb
187
194
  - lib/legion/extensions/agentic/social/conflict/actors/stale_check.rb
188
195
  - lib/legion/extensions/agentic/social/conflict/client.rb
@@ -317,6 +324,10 @@ files:
317
324
  - spec/legion/extensions/agentic/social/apprenticeship/helpers/apprenticeship_model_spec.rb
318
325
  - spec/legion/extensions/agentic/social/apprenticeship/helpers/apprenticeship_spec.rb
319
326
  - spec/legion/extensions/agentic/social/apprenticeship/runners/cognitive_apprenticeship_spec.rb
327
+ - spec/legion/extensions/agentic/social/attachment/helpers/attachment_model_spec.rb
328
+ - spec/legion/extensions/agentic/social/attachment/helpers/attachment_store_spec.rb
329
+ - spec/legion/extensions/agentic/social/attachment/helpers/constants_spec.rb
330
+ - spec/legion/extensions/agentic/social/attachment/runners/attachment_spec.rb
320
331
  - spec/legion/extensions/agentic/social/conflict/actors/stale_check_spec.rb
321
332
  - spec/legion/extensions/agentic/social/conflict/client_spec.rb
322
333
  - spec/legion/extensions/agentic/social/conflict/helpers/conflict_log_spec.rb