lex-agentic-self 0.1.8 → 0.1.10

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: 0c660e26600da2bf1c6e0ec3ff8c8d81a1eabebc3859d3de838e30f22b4ba91d
4
- data.tar.gz: ec3d26a5dd95ad077de1880fd60f48d23ff0a34aa841f82cda7bcbfdad99e5d0
3
+ metadata.gz: '081b534c7eaf4efbc0265eada2d3b8f11b37b93a3fb34be77068d0ce4f0e842d'
4
+ data.tar.gz: ce8a3839f617dc41d904ccf54a98a208037178cd89eefb39cd031fce8a89e315
5
5
  SHA512:
6
- metadata.gz: ffbe4a9cee92f015c0d167fa98327aceb9641c8dd448d99f0649ff42e87eb531b51eec961729f615e274c2854422cdb0b3e2cc77d02f456b9ceeb944a6088ebb
7
- data.tar.gz: f418467e5090f41939297b8907288f4ef29b95fbc9def510bbc1efb503abd79cdf8a3420b7ec2ce707d5d29d06e0569531da88395658a3901b80a8d7356b53b9
6
+ metadata.gz: 49409e3e358267476770c66db88a4eff2f7968857c130e547cc3170a3c3e891b27358d7b28ac77e81e5733d480ab65480b38c10c908ef9323738b4159bab527e
7
+ data.tar.gz: fd913ef78cfa8b14ccb5bdadbd95e3133e5bcf67d21a40332ba360ba6393ebd1b34e7db0ccebe3a4bf177aea506d1fc666dcb0efc89820e26853e0574888998d
data/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.1.10] - 2026-03-31
4
+
5
+ ### Added
6
+ - RelationshipArc sub-module for Phase C relational intelligence
7
+ - Constants: chapters, milestone types, health weights, chapter thresholds
8
+ - Milestone: typed data class with UUID, significance clamping, serialization
9
+ - ArcEngine: chapter progression, milestone tracking, relationship health, Apollo Local persistence
10
+ - RelationshipArc runner: record_milestone, update_arc, arc_stats with NarrativeIdentity episode stamping
11
+
12
+ ## [0.1.9] - 2026-03-31
13
+
14
+ ### Added
15
+ - add `PARTNER_SIGNAL_MAP` and `PARTNER_SIGNAL_THRESHOLD` constants to Personality::Helpers::Constants for partner-specific OCEAN nudges (weight 0.2 per signal)
16
+ - add `TraitModel#apply_partner_signals` to nudge extraversion, agreeableness, openness, and conscientiousness from partner engagement patterns; signals below threshold (0.3) are ignored
17
+ - wire partner signal extraction into `PersonalityStore#update` via `tick_results[:social]` reputation data (engagement frequency, direct address ratio, content diversity, consistency)
18
+ - add 16 specs covering PARTNER_SIGNAL_MAP entries, threshold gating, multi-signal application, and no observation_count side-effect
19
+
3
20
  ## [0.1.8] - 2026-03-30
4
21
 
5
22
  ### Fixed
@@ -70,6 +70,20 @@ module Legion
70
70
  mood_stability: [:neuroticism, :negative, 0.3]
71
71
  }.freeze
72
72
 
73
+ # Partner-specific signal map: partner interaction patterns that slowly nudge OCEAN traits.
74
+ # Lower weight (0.2) than general signals — personality should drift very slowly from
75
+ # partner engagement alone.
76
+ # Each entry: [trait, direction, weight]
77
+ PARTNER_SIGNAL_MAP = {
78
+ partner_engagement_frequency: [:extraversion, :positive, 0.2],
79
+ partner_direct_address_ratio: [:agreeableness, :positive, 0.2],
80
+ partner_content_diversity: [:openness, :positive, 0.2],
81
+ partner_consistency: [:conscientiousness, :positive, 0.2]
82
+ }.freeze
83
+
84
+ # Minimum signal value required to apply a partner nudge (0.0–1.0)
85
+ PARTNER_SIGNAL_THRESHOLD = 0.3
86
+
73
87
  # Threshold for "high" trait descriptor
74
88
  HIGH_THRESHOLD = 0.65
75
89
 
@@ -16,6 +16,8 @@ module Legion
16
16
  def update(tick_results)
17
17
  signals = extract_signals(tick_results)
18
18
  @model.update(signals)
19
+ partner_signals = extract_partner_signals(tick_results)
20
+ @model.apply_partner_signals(partner_signals) unless partner_signals.empty?
19
21
  end
20
22
 
21
23
  def full_description
@@ -41,6 +43,42 @@ module Legion
41
43
 
42
44
  private
43
45
 
46
+ def extract_partner_signals(tick_results)
47
+ counts = partner_records(tick_results)
48
+ return {} if counts.empty?
49
+
50
+ {
51
+ partner_engagement_frequency: partner_engagement_frequency(counts),
52
+ partner_direct_address_ratio: avg_partner_field(counts, :direct_address_ratio),
53
+ partner_content_diversity: avg_partner_diversity(counts),
54
+ partner_consistency: avg_partner_field(counts, :consistency)
55
+ }.compact
56
+ end
57
+
58
+ def partner_records(tick_results)
59
+ social = tick_results[:social] || tick_results[:social_cognition] || {}
60
+ return [] unless social.is_a?(Hash)
61
+
62
+ reputation = social[:reputation_updates] || social[:partners] || {}
63
+ return [] unless reputation.is_a?(Hash) && reputation.any?
64
+
65
+ reputation.values.grep(Hash)
66
+ end
67
+
68
+ def partner_engagement_frequency(counts)
69
+ (counts.count { |p| p[:message_count].to_i.positive? } / counts.size.to_f).clamp(0.0, 1.0)
70
+ end
71
+
72
+ def avg_partner_field(counts, key)
73
+ vals = counts.filter_map { |p| p[key] }.grep(Numeric)
74
+ vals.any? ? (vals.sum / vals.size.to_f).clamp(0.0, 1.0) : nil
75
+ end
76
+
77
+ def avg_partner_diversity(counts)
78
+ vals = counts.filter_map { |p| p[:topic_diversity] || p[:content_diversity] }.grep(Numeric)
79
+ vals.any? ? (vals.sum / vals.size.to_f).clamp(0.0, 1.0) : nil
80
+ end
81
+
44
82
  def extract_signals(tick_results)
45
83
  signals = {}
46
84
 
@@ -30,6 +30,14 @@ module Legion
30
30
  @traits[name.to_sym]
31
31
  end
32
32
 
33
+ def apply_partner_signals(signals)
34
+ observations = extract_partner_observations(signals)
35
+ return if observations.empty?
36
+
37
+ apply_observations(observations)
38
+ record_snapshot
39
+ end
40
+
33
41
  def formed?
34
42
  @observation_count >= Constants::FORMATION_THRESHOLD
35
43
  end
@@ -105,6 +113,21 @@ module Legion
105
113
 
106
114
  private
107
115
 
116
+ def extract_partner_observations(signals)
117
+ observations = Hash.new { |h, k| h[k] = [] }
118
+
119
+ Constants::PARTNER_SIGNAL_MAP.each do |signal_key, (trait, direction, weight)|
120
+ value = signals[signal_key]
121
+ next unless value.is_a?(Numeric)
122
+ next if value <= Constants::PARTNER_SIGNAL_THRESHOLD
123
+
124
+ effective = direction == :positive ? value : 1.0 - value
125
+ observations[trait] << { value: effective, weight: weight }
126
+ end
127
+
128
+ observations
129
+ end
130
+
108
131
  def extract_observations(signals)
109
132
  observations = Hash.new { |h, k| h[k] = [] }
110
133
 
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/extensions/agentic/self/relationship_arc/helpers/constants'
4
+ require 'legion/extensions/agentic/self/relationship_arc/helpers/milestone'
5
+ require 'legion/extensions/agentic/self/relationship_arc/helpers/arc_engine'
6
+ require 'legion/extensions/agentic/self/relationship_arc/runners/relationship_arc'
7
+
8
+ module Legion
9
+ module Extensions
10
+ module Agentic
11
+ module Self
12
+ module RelationshipArc
13
+ class Client
14
+ include Runners::RelationshipArc
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Agentic
6
+ module Self
7
+ module RelationshipArc
8
+ module Helpers
9
+ class ArcEngine
10
+ attr_reader :agent_id, :current_chapter, :milestones
11
+
12
+ BOND_STAGE_ORDER = %i[initial forming established deep].freeze
13
+ private_constant :BOND_STAGE_ORDER
14
+
15
+ def initialize(agent_id:)
16
+ @agent_id = agent_id
17
+ @current_chapter = :formative
18
+ @milestones = []
19
+ @dirty = false
20
+ end
21
+
22
+ def add_milestone(type:, description:, significance:, **)
23
+ ms = Milestone.new(type: type, description: description, significance: significance)
24
+ @milestones << ms
25
+ @milestones.shift while @milestones.size > Constants::MAX_MILESTONES
26
+ @dirty = true
27
+ ms
28
+ end
29
+
30
+ def update_chapter!(bond_stage: :initial, **)
31
+ new_chapter = derive_chapter(bond_stage)
32
+ return if Constants::CHAPTERS.index(new_chapter) <= Constants::CHAPTERS.index(@current_chapter)
33
+
34
+ @current_chapter = new_chapter
35
+ @dirty = true
36
+ end
37
+
38
+ def relationship_health(attachment_strength: 0.0, reciprocity_balance: 0.5,
39
+ communication_consistency: 0.5, **)
40
+ w = Constants::HEALTH_WEIGHTS
41
+ score = (attachment_strength.to_f * w[:attachment_strength]) +
42
+ (reciprocity_balance.to_f * w[:reciprocity_balance]) +
43
+ (communication_consistency.to_f * w[:communication_consistency])
44
+ score.clamp(0.0, 1.0)
45
+ end
46
+
47
+ def dirty?
48
+ @dirty
49
+ end
50
+
51
+ def mark_clean!
52
+ @dirty = false
53
+ self
54
+ end
55
+
56
+ def to_apollo_entries
57
+ tags = Constants::TAG_PREFIX.dup + [@agent_id]
58
+ tags << 'partner' if partner?(@agent_id)
59
+ [{ content: serialize(arc_state_hash), tags: tags }]
60
+ end
61
+
62
+ def from_apollo(store:)
63
+ result = store.query(text: 'relationship_arc', tags: Constants::TAG_PREFIX + [@agent_id])
64
+ return false unless result[:success] && result[:results]&.any?
65
+
66
+ parsed = deserialize(result[:results].first[:content])
67
+ return false unless parsed
68
+
69
+ @current_chapter = parsed[:current_chapter]&.to_sym || :formative
70
+ @milestones = (parsed[:milestones] || []).map { |mh| Milestone.from_h(mh.transform_keys(&:to_sym)) }
71
+ true
72
+ rescue StandardError => e
73
+ warn "[arc_engine] from_apollo error: #{e.message}"
74
+ false
75
+ end
76
+
77
+ def to_h
78
+ { agent_id: @agent_id, current_chapter: @current_chapter,
79
+ milestones: @milestones.map(&:to_h),
80
+ relationship_health: nil, milestone_count: @milestones.size }
81
+ end
82
+
83
+ private
84
+
85
+ def arc_state_hash
86
+ { agent_id: @agent_id, current_chapter: @current_chapter,
87
+ milestones: @milestones.map(&:to_h) }
88
+ end
89
+
90
+ def derive_chapter(bond_stage)
91
+ Constants::CHAPTER_THRESHOLDS.each_key.reverse_each do |chapter|
92
+ threshold = Constants::CHAPTER_THRESHOLDS[chapter]
93
+ stage_idx = BOND_STAGE_ORDER.index(threshold[:stage]) || 0
94
+ current_stage_idx = BOND_STAGE_ORDER.index(bond_stage) || 0
95
+
96
+ return chapter if @milestones.size >= threshold[:milestones] &&
97
+ current_stage_idx >= stage_idx
98
+ end
99
+ :formative
100
+ end
101
+
102
+ def partner?(agent_id)
103
+ defined?(Legion::Gaia::BondRegistry) && Legion::Gaia::BondRegistry.partner?(agent_id)
104
+ end
105
+
106
+ def serialize(hash)
107
+ defined?(Legion::JSON) ? Legion::JSON.dump(hash) : ::JSON.dump(hash)
108
+ end
109
+
110
+ def deserialize(content)
111
+ parsed = defined?(Legion::JSON) ? Legion::JSON.parse(content) : ::JSON.parse(content, symbolize_names: true)
112
+ parsed.is_a?(Hash) ? parsed.transform_keys(&:to_sym) : nil
113
+ rescue StandardError => _e
114
+ nil
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Agentic
6
+ module Self
7
+ module RelationshipArc
8
+ module Helpers
9
+ module Constants
10
+ CHAPTERS = %i[formative developing established deepening].freeze
11
+
12
+ MILESTONE_TYPES = %i[
13
+ first_interaction first_direct_address stage_transition
14
+ prediction_accuracy communication_shift absence_return
15
+ ].freeze
16
+
17
+ HEALTH_WEIGHTS = {
18
+ attachment_strength: 0.4,
19
+ reciprocity_balance: 0.3,
20
+ communication_consistency: 0.3
21
+ }.freeze
22
+
23
+ MAX_MILESTONES = 200
24
+
25
+ CHAPTER_THRESHOLDS = {
26
+ developing: { milestones: 3, stage: :forming },
27
+ established: { milestones: 10, stage: :established },
28
+ deepening: { milestones: 25, stage: :deep }
29
+ }.freeze
30
+
31
+ TAG_PREFIX = %w[bond relationship_arc].freeze
32
+ MILESTONE_TAG_PREFIX = %w[bond milestone].freeze
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module Legion
6
+ module Extensions
7
+ module Agentic
8
+ module Self
9
+ module RelationshipArc
10
+ module Helpers
11
+ class Milestone
12
+ attr_reader :id, :type, :description, :significance, :created_at
13
+
14
+ def initialize(type:, description:, significance:, id: nil, created_at: nil)
15
+ @id = id || SecureRandom.uuid
16
+ @type = type.to_sym
17
+ @description = description
18
+ @significance = significance.to_f.clamp(0.0, 1.0)
19
+ @created_at = created_at || Time.now.utc
20
+ end
21
+
22
+ def to_h
23
+ { id: @id, type: @type, description: @description,
24
+ significance: @significance, created_at: @created_at.iso8601 }
25
+ end
26
+
27
+ def self.from_h(hash)
28
+ new(
29
+ id: hash[:id],
30
+ type: hash[:type],
31
+ description: hash[:description],
32
+ significance: hash[:significance],
33
+ created_at: hash[:created_at] ? Time.parse(hash[:created_at].to_s) : nil
34
+ )
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Agentic
6
+ module Self
7
+ module RelationshipArc
8
+ module Runners
9
+ module RelationshipArc
10
+ include Legion::Extensions::Helpers::Lex if defined?(Legion::Extensions::Helpers) &&
11
+ Legion::Extensions::Helpers.const_defined?(:Lex, false)
12
+
13
+ def record_milestone(agent_id:, type:, description:, significance:, **)
14
+ unless Helpers::Constants::MILESTONE_TYPES.include?(type.to_sym)
15
+ return { success: false,
16
+ error: "unknown type: #{type}" }
17
+ end
18
+
19
+ engine = arc_engine_for(agent_id)
20
+ ms = engine.add_milestone(type: type, description: description, significance: significance)
21
+
22
+ stamp_narrative_episode(ms)
23
+
24
+ { success: true, milestone: ms.to_h }
25
+ rescue StandardError => e
26
+ { success: false, error: e.message }
27
+ end
28
+
29
+ def update_arc(agent_id:, attachment_state: {}, **)
30
+ engine = arc_engine_for(agent_id)
31
+ engine.update_chapter!(bond_stage: attachment_state[:bond_stage] || :initial)
32
+
33
+ { success: true, current_chapter: engine.current_chapter,
34
+ milestone_count: engine.milestones.size }
35
+ rescue StandardError => e
36
+ { success: false, error: e.message }
37
+ end
38
+
39
+ def arc_stats(agent_id:, **)
40
+ engine = arc_engine_for(agent_id)
41
+ engine.to_h
42
+ end
43
+
44
+ private
45
+
46
+ def arc_engine_for(agent_id)
47
+ @arc_engines ||= {}
48
+ @arc_engines[agent_id.to_s] ||= Helpers::ArcEngine.new(agent_id: agent_id.to_s)
49
+ end
50
+
51
+ def stamp_narrative_episode(milestone)
52
+ narrator = resolve_narrative_identity
53
+ return unless narrator
54
+
55
+ narrator.record_episode(
56
+ content: milestone.description,
57
+ episode_type: :relationship,
58
+ emotional_valence: 0.3,
59
+ significance: milestone.significance,
60
+ domain: :relationship,
61
+ tags: ['partner', 'milestone', milestone.type.to_s]
62
+ )
63
+ rescue StandardError => e
64
+ warn "[relationship_arc] narrative stamp failed: #{e.message}"
65
+ end
66
+
67
+ def resolve_narrative_identity
68
+ return nil unless defined?(Legion::Extensions::Agentic::Self::NarrativeIdentity::Client)
69
+
70
+ @narrative_client ||= Legion::Extensions::Agentic::Self::NarrativeIdentity::Client.new
71
+ rescue StandardError => _e
72
+ nil
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Agentic
6
+ module Self
7
+ module RelationshipArc
8
+ VERSION = '0.1.0'
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'relationship_arc/version'
4
+ require_relative 'relationship_arc/helpers/constants'
5
+ require_relative 'relationship_arc/helpers/milestone'
6
+ require_relative 'relationship_arc/helpers/arc_engine'
7
+ require_relative 'relationship_arc/runners/relationship_arc'
8
+ require_relative 'relationship_arc/client'
9
+
10
+ module Legion
11
+ module Extensions
12
+ module Agentic
13
+ module Self
14
+ module RelationshipArc
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -4,7 +4,7 @@ module Legion
4
4
  module Extensions
5
5
  module Agentic
6
6
  module Self
7
- VERSION = '0.1.8'
7
+ VERSION = '0.1.10'
8
8
  end
9
9
  end
10
10
  end
@@ -17,6 +17,7 @@ require_relative 'self/agency'
17
17
  require_relative 'self/reflection'
18
18
  require_relative 'self/anosognosia'
19
19
  require_relative 'self/default_mode_network'
20
+ require_relative 'self/relationship_arc'
20
21
 
21
22
  module Legion
22
23
  module Extensions
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::Agentic::Self::Personality::Helpers::TraitModel do
4
+ subject(:model) { described_class.new }
5
+
6
+ let(:constants) { Legion::Extensions::Agentic::Self::Personality::Helpers::Constants }
7
+
8
+ describe 'PARTNER_SIGNAL_MAP constant' do
9
+ it 'defines partner_engagement_frequency nudging extraversion positively' do
10
+ entry = constants::PARTNER_SIGNAL_MAP[:partner_engagement_frequency]
11
+ expect(entry).to eq([:extraversion, :positive, 0.2])
12
+ end
13
+
14
+ it 'defines partner_direct_address_ratio nudging agreeableness positively' do
15
+ entry = constants::PARTNER_SIGNAL_MAP[:partner_direct_address_ratio]
16
+ expect(entry).to eq([:agreeableness, :positive, 0.2])
17
+ end
18
+
19
+ it 'defines partner_content_diversity nudging openness positively' do
20
+ entry = constants::PARTNER_SIGNAL_MAP[:partner_content_diversity]
21
+ expect(entry).to eq([:openness, :positive, 0.2])
22
+ end
23
+
24
+ it 'defines partner_consistency nudging conscientiousness positively' do
25
+ entry = constants::PARTNER_SIGNAL_MAP[:partner_consistency]
26
+ expect(entry).to eq([:conscientiousness, :positive, 0.2])
27
+ end
28
+
29
+ it 'has all entries with weights of 0.2' do
30
+ constants::PARTNER_SIGNAL_MAP.each_value do |_trait, _direction, weight|
31
+ expect(weight).to eq(0.2)
32
+ end
33
+ end
34
+
35
+ it 'references only valid OCEAN traits' do
36
+ constants::PARTNER_SIGNAL_MAP.each_value do |trait, _direction, _weight|
37
+ expect(constants::TRAITS).to include(trait)
38
+ end
39
+ end
40
+ end
41
+
42
+ describe '#apply_partner_signals' do
43
+ it 'nudges extraversion upward when partner_engagement_frequency is high' do
44
+ baseline = model.trait(:extraversion)
45
+ model.apply_partner_signals(partner_engagement_frequency: 0.9)
46
+ expect(model.trait(:extraversion)).to be > baseline
47
+ end
48
+
49
+ it 'nudges agreeableness upward when partner_direct_address_ratio is high' do
50
+ baseline = model.trait(:agreeableness)
51
+ model.apply_partner_signals(partner_direct_address_ratio: 0.8)
52
+ expect(model.trait(:agreeableness)).to be > baseline
53
+ end
54
+
55
+ it 'nudges openness upward when partner_content_diversity is high' do
56
+ baseline = model.trait(:openness)
57
+ model.apply_partner_signals(partner_content_diversity: 0.8)
58
+ expect(model.trait(:openness)).to be > baseline
59
+ end
60
+
61
+ it 'nudges conscientiousness upward when partner_consistency is high' do
62
+ baseline = model.trait(:conscientiousness)
63
+ model.apply_partner_signals(partner_consistency: 0.8)
64
+ expect(model.trait(:conscientiousness)).to be > baseline
65
+ end
66
+
67
+ it 'ignores signals below the minimum threshold' do
68
+ baseline = model.trait(:extraversion)
69
+ model.apply_partner_signals(partner_engagement_frequency: 0.1)
70
+ expect(model.trait(:extraversion)).to eq(baseline)
71
+ end
72
+
73
+ it 'ignores signals exactly at the threshold boundary' do
74
+ baseline = model.trait(:extraversion)
75
+ model.apply_partner_signals(partner_engagement_frequency: 0.3)
76
+ expect(model.trait(:extraversion)).to eq(baseline)
77
+ end
78
+
79
+ it 'applies multiple signals in a single call' do
80
+ extraversion_before = model.trait(:extraversion)
81
+ agreeableness_before = model.trait(:agreeableness)
82
+
83
+ model.apply_partner_signals(
84
+ partner_engagement_frequency: 0.9,
85
+ partner_direct_address_ratio: 0.8
86
+ )
87
+
88
+ expect(model.trait(:extraversion)).to be > extraversion_before
89
+ expect(model.trait(:agreeableness)).to be > agreeableness_before
90
+ end
91
+
92
+ it 'ignores unrecognized signal keys' do
93
+ expect { model.apply_partner_signals(unknown_signal: 0.9) }.not_to raise_error
94
+ end
95
+
96
+ it 'does not change observation_count' do
97
+ model.apply_partner_signals(partner_engagement_frequency: 0.9)
98
+ expect(model.observation_count).to eq(0)
99
+ end
100
+
101
+ it 'records a history snapshot after applying signals' do
102
+ model.apply_partner_signals(partner_engagement_frequency: 0.9)
103
+ expect(model.history.size).to eq(1)
104
+ end
105
+ end
106
+ end
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'legion/extensions/agentic/self/relationship_arc/helpers/constants'
5
+ require 'legion/extensions/agentic/self/relationship_arc/helpers/milestone'
6
+ require 'legion/extensions/agentic/self/relationship_arc/helpers/arc_engine'
7
+
8
+ RSpec.describe Legion::Extensions::Agentic::Self::RelationshipArc::Helpers::ArcEngine do
9
+ subject(:engine) { described_class.new(agent_id: 'partner-1') }
10
+
11
+ describe '#initialize' do
12
+ it 'starts in formative chapter' do
13
+ expect(engine.current_chapter).to eq(:formative)
14
+ end
15
+
16
+ it 'starts with empty milestones' do
17
+ expect(engine.milestones).to be_empty
18
+ end
19
+ end
20
+
21
+ describe '#add_milestone' do
22
+ it 'adds a milestone' do
23
+ engine.add_milestone(type: :first_interaction, description: 'Hello', significance: 0.8)
24
+ expect(engine.milestones.size).to eq(1)
25
+ end
26
+
27
+ it 'returns the milestone' do
28
+ ms = engine.add_milestone(type: :first_interaction, description: 'Hello', significance: 0.8)
29
+ expect(ms).to be_a(Legion::Extensions::Agentic::Self::RelationshipArc::Helpers::Milestone)
30
+ end
31
+
32
+ it 'marks dirty' do
33
+ engine.add_milestone(type: :first_interaction, description: 'Hello', significance: 0.8)
34
+ expect(engine).to be_dirty
35
+ end
36
+
37
+ it 'caps at MAX_MILESTONES' do
38
+ 201.times { |i| engine.add_milestone(type: :first_interaction, description: "ms #{i}", significance: 0.1) }
39
+ expect(engine.milestones.size).to eq(200)
40
+ end
41
+ end
42
+
43
+ describe '#update_chapter!' do
44
+ it 'transitions to developing after enough milestones' do
45
+ 4.times { engine.add_milestone(type: :first_interaction, description: 'x', significance: 0.5) }
46
+ engine.update_chapter!(bond_stage: :forming)
47
+ expect(engine.current_chapter).to eq(:developing)
48
+ end
49
+
50
+ it 'never regresses' do
51
+ engine.instance_variable_set(:@current_chapter, :established)
52
+ engine.update_chapter!(bond_stage: :initial)
53
+ expect(engine.current_chapter).to eq(:established)
54
+ end
55
+ end
56
+
57
+ describe '#relationship_health' do
58
+ it 'computes weighted health score' do
59
+ health = engine.relationship_health(
60
+ attachment_strength: 0.8,
61
+ reciprocity_balance: 0.6,
62
+ communication_consistency: 0.7
63
+ )
64
+ expected = (0.8 * 0.4) + (0.6 * 0.3) + (0.7 * 0.3)
65
+ expect(health).to be_within(0.01).of(expected)
66
+ end
67
+
68
+ it 'clamps to 0.0..1.0' do
69
+ health = engine.relationship_health(
70
+ attachment_strength: 1.5,
71
+ reciprocity_balance: 1.5,
72
+ communication_consistency: 1.5
73
+ )
74
+ expect(health).to eq(1.0)
75
+ end
76
+ end
77
+
78
+ describe '#dirty? and #mark_clean!' do
79
+ it 'starts clean' do
80
+ expect(engine).not_to be_dirty
81
+ end
82
+
83
+ it 'cleans up' do
84
+ engine.add_milestone(type: :first_interaction, description: 'x', significance: 0.5)
85
+ engine.mark_clean!
86
+ expect(engine).not_to be_dirty
87
+ end
88
+ end
89
+
90
+ describe '#to_apollo_entries' do
91
+ before { engine.add_milestone(type: :first_interaction, description: 'Hello', significance: 0.8) }
92
+
93
+ it 'returns entries with arc state' do
94
+ entries = engine.to_apollo_entries
95
+ expect(entries).to be_an(Array)
96
+ expect(entries.first[:tags]).to include('bond', 'relationship_arc', 'partner-1')
97
+ end
98
+ end
99
+
100
+ describe '#from_apollo' do
101
+ let(:mock_store) { double('apollo_local') }
102
+
103
+ it 'restores state from Apollo' do
104
+ engine.add_milestone(type: :first_interaction, description: 'Hello', significance: 0.8)
105
+ engine.instance_variable_set(:@current_chapter, :developing)
106
+ content = engine.send(:serialize, engine.send(:arc_state_hash))
107
+
108
+ new_engine = described_class.new(agent_id: 'partner-1')
109
+ allow(mock_store).to receive(:query)
110
+ .and_return({ success: true, results: [{ content: content, tags: %w[bond relationship_arc partner-1] }] })
111
+
112
+ expect(new_engine.from_apollo(store: mock_store)).to be true
113
+ expect(new_engine.current_chapter).to eq(:developing)
114
+ expect(new_engine.milestones.size).to eq(1)
115
+ end
116
+ end
117
+
118
+ describe '#to_h' do
119
+ it 'includes all fields' do
120
+ h = engine.to_h
121
+ expect(h).to include(:agent_id, :current_chapter, :milestones, :relationship_health)
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'legion/extensions/agentic/self/relationship_arc/helpers/constants'
5
+
6
+ RSpec.describe Legion::Extensions::Agentic::Self::RelationshipArc::Helpers::Constants do
7
+ describe 'CHAPTERS' do
8
+ it 'defines 4 chapters in order' do
9
+ expect(described_class::CHAPTERS).to eq(%i[formative developing established deepening])
10
+ end
11
+ end
12
+
13
+ describe 'MILESTONE_TYPES' do
14
+ it 'includes expected types' do
15
+ expect(described_class::MILESTONE_TYPES).to include(:first_interaction, :stage_transition,
16
+ :prediction_accuracy, :absence_return)
17
+ end
18
+ end
19
+
20
+ describe 'HEALTH_WEIGHTS' do
21
+ it 'sums to 1.0' do
22
+ expect(described_class::HEALTH_WEIGHTS.values.sum).to eq(1.0)
23
+ end
24
+ end
25
+
26
+ describe 'MAX_MILESTONES' do
27
+ it 'caps at 200' do
28
+ expect(described_class::MAX_MILESTONES).to eq(200)
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'legion/extensions/agentic/self/relationship_arc/helpers/constants'
5
+ require 'legion/extensions/agentic/self/relationship_arc/helpers/milestone'
6
+
7
+ RSpec.describe Legion::Extensions::Agentic::Self::RelationshipArc::Helpers::Milestone do
8
+ subject(:milestone) do
9
+ described_class.new(type: :first_interaction, description: 'First hello', significance: 0.8)
10
+ end
11
+
12
+ describe '#initialize' do
13
+ it 'sets type' do
14
+ expect(milestone.type).to eq(:first_interaction)
15
+ end
16
+
17
+ it 'generates a UUID' do
18
+ expect(milestone.id).to match(/\A[0-9a-f-]{36}\z/)
19
+ end
20
+
21
+ it 'clamps significance to 0.0..1.0' do
22
+ ms = described_class.new(type: :first_interaction, description: 'test', significance: 1.5)
23
+ expect(ms.significance).to eq(1.0)
24
+ end
25
+
26
+ it 'records timestamp' do
27
+ expect(milestone.created_at).to be_a(Time)
28
+ end
29
+ end
30
+
31
+ describe '#to_h' do
32
+ it 'returns complete hash' do
33
+ h = milestone.to_h
34
+ expect(h).to include(:id, :type, :description, :significance, :created_at)
35
+ end
36
+ end
37
+
38
+ describe '.from_h' do
39
+ it 'round-trips' do
40
+ restored = described_class.from_h(milestone.to_h)
41
+ expect(restored.type).to eq(milestone.type)
42
+ expect(restored.description).to eq(milestone.description)
43
+ expect(restored.significance).to eq(milestone.significance)
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+ require 'legion/extensions/agentic/self/relationship_arc/helpers/constants'
5
+ require 'legion/extensions/agentic/self/relationship_arc/helpers/milestone'
6
+ require 'legion/extensions/agentic/self/relationship_arc/helpers/arc_engine'
7
+ require 'legion/extensions/agentic/self/relationship_arc/runners/relationship_arc'
8
+
9
+ RSpec.describe Legion::Extensions::Agentic::Self::RelationshipArc::Runners::RelationshipArc do
10
+ let(:described_module) { described_class }
11
+ let(:host) { Object.new.extend(described_module) }
12
+
13
+ before { host.instance_variable_set(:@arc_engines, nil) }
14
+
15
+ describe '#record_milestone' do
16
+ it 'records a milestone for an agent' do
17
+ result = host.record_milestone(agent_id: 'p1', type: :first_interaction,
18
+ description: 'Hello', significance: 0.8)
19
+ expect(result[:success]).to be true
20
+ expect(result[:milestone][:type]).to eq(:first_interaction)
21
+ end
22
+
23
+ it 'returns error for unknown type' do
24
+ result = host.record_milestone(agent_id: 'p1', type: :bogus,
25
+ description: 'x', significance: 0.5)
26
+ expect(result[:success]).to be false
27
+ end
28
+
29
+ it 'calls NarrativeIdentity record_episode when available' do
30
+ narrator = double('narrator')
31
+ allow(host).to receive(:resolve_narrative_identity).and_return(narrator)
32
+ allow(narrator).to receive(:record_episode).and_return({ success: true })
33
+
34
+ host.record_milestone(agent_id: 'p1', type: :first_interaction,
35
+ description: 'Hello', significance: 0.8)
36
+ expect(narrator).to have_received(:record_episode)
37
+ .with(hash_including(episode_type: :relationship, significance: 0.8))
38
+ end
39
+ end
40
+
41
+ describe '#update_arc' do
42
+ it 'updates chapter based on attachment state' do
43
+ 3.times do
44
+ host.record_milestone(agent_id: 'p1', type: :first_interaction,
45
+ description: 'x', significance: 0.5)
46
+ end
47
+ result = host.update_arc(agent_id: 'p1', attachment_state: { bond_stage: :forming })
48
+ expect(result[:success]).to be true
49
+ end
50
+
51
+ it 'returns arc summary' do
52
+ result = host.update_arc(agent_id: 'p1', attachment_state: {})
53
+ expect(result).to have_key(:current_chapter)
54
+ expect(result).to have_key(:milestone_count)
55
+ end
56
+ end
57
+
58
+ describe '#arc_stats' do
59
+ it 'returns stats' do
60
+ result = host.arc_stats(agent_id: 'p1')
61
+ expect(result).to have_key(:current_chapter)
62
+ end
63
+ end
64
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lex-agentic-self
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.8
4
+ version: 0.1.10
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -309,6 +309,13 @@ files:
309
309
  - lib/legion/extensions/agentic/self/reflection/helpers/reflection_store.rb
310
310
  - lib/legion/extensions/agentic/self/reflection/runners/reflection.rb
311
311
  - lib/legion/extensions/agentic/self/reflection/version.rb
312
+ - lib/legion/extensions/agentic/self/relationship_arc.rb
313
+ - lib/legion/extensions/agentic/self/relationship_arc/client.rb
314
+ - lib/legion/extensions/agentic/self/relationship_arc/helpers/arc_engine.rb
315
+ - lib/legion/extensions/agentic/self/relationship_arc/helpers/constants.rb
316
+ - lib/legion/extensions/agentic/self/relationship_arc/helpers/milestone.rb
317
+ - lib/legion/extensions/agentic/self/relationship_arc/runners/relationship_arc.rb
318
+ - lib/legion/extensions/agentic/self/relationship_arc/version.rb
312
319
  - lib/legion/extensions/agentic/self/self_model.rb
313
320
  - lib/legion/extensions/agentic/self/self_model/client.rb
314
321
  - lib/legion/extensions/agentic/self/self_model/helpers/capability.rb
@@ -406,6 +413,7 @@ files:
406
413
  - spec/legion/extensions/agentic/self/narrative_self/runners/narrative_self_spec.rb
407
414
  - spec/legion/extensions/agentic/self/personality/client_spec.rb
408
415
  - spec/legion/extensions/agentic/self/personality/helpers/constants_spec.rb
416
+ - spec/legion/extensions/agentic/self/personality/helpers/partner_signals_spec.rb
409
417
  - spec/legion/extensions/agentic/self/personality/helpers/personality_store_spec.rb
410
418
  - spec/legion/extensions/agentic/self/personality/helpers/trait_model_spec.rb
411
419
  - spec/legion/extensions/agentic/self/personality/runners/personality_spec.rb
@@ -415,6 +423,10 @@ files:
415
423
  - spec/legion/extensions/agentic/self/reflection/helpers/reflection_spec.rb
416
424
  - spec/legion/extensions/agentic/self/reflection/helpers/reflection_store_spec.rb
417
425
  - spec/legion/extensions/agentic/self/reflection/runners/reflection_spec.rb
426
+ - spec/legion/extensions/agentic/self/relationship_arc/helpers/arc_engine_spec.rb
427
+ - spec/legion/extensions/agentic/self/relationship_arc/helpers/constants_spec.rb
428
+ - spec/legion/extensions/agentic/self/relationship_arc/helpers/milestone_spec.rb
429
+ - spec/legion/extensions/agentic/self/relationship_arc/runners/relationship_arc_spec.rb
418
430
  - spec/legion/extensions/agentic/self/self_model/client_spec.rb
419
431
  - spec/legion/extensions/agentic/self/self_model/helpers/capability_spec.rb
420
432
  - spec/legion/extensions/agentic/self/self_model/helpers/knowledge_domain_spec.rb