lex-agentic-affect 0.1.6 → 0.1.7

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: d3edfb8ca48c97428f04fd913693fcffb129c1ee5bb8779be2dbd61ffeeeeed6
4
- data.tar.gz: 6443631b15f56be9897948a5149912af86d203e81cc893ad86fe6e8d258e2122
3
+ metadata.gz: 74630b3aaa2366764ebb2af5fe332a6ce6b51778f2c6bcc22669348e051bf9f7
4
+ data.tar.gz: a241ea19f718a589b0bb0a3ef609d7dbf16bfad7ada03cb2a29288910779a26c
5
5
  SHA512:
6
- metadata.gz: d28e2998e7ae2f15c19754565e8bb2fa92b67d4a9727f9e9d232cbff5c39e235570da1aa2e306363a1cc6a15f7476794bdc12bcebe7690efb0e1cc5819bc3926
7
- data.tar.gz: 8d6c801d3769cb4b10db6db28eb62e81726c8213981e1fb5d2c92b61e9b625c714c7ce48f39928315e22cd406601992a165ce9db053d1f9b356c3b081bc13b74
6
+ metadata.gz: 560024c502ab7b4b43995b330dd7b17f8f7b086c8afbcbd9c48798d895a25ada189bb3e9deb34ce606be7206cb4a6264df98940c05b811bda0c560dc11317f12
7
+ data.tar.gz: fd7148b82824f8d2079bbdf8508bd9ff1031d11eb68ee00d78ca2eb52f6848dc401c0d444d976b8761224f2f7f90bb9ed8e9f6e46a85dbdd5c2056e0882b14b4
data/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.1.7] - 2026-03-31
4
+
5
+ ### Added
6
+ - Empathy: `MentalModel` tracks `bond_role` and `channel`; partners start with 0.8 confidence
7
+ - Empathy: `ModelStore#update_from_human_observation` processes GAIA partner observation hashes
8
+ - Empathy: `ModelStore` Apollo Local persistence — `dirty?`, `mark_clean!`, `to_apollo_entries`, `from_apollo`
9
+ - Empathy runner: `observe_human_observations(human_observations:)` processes GAIA-passed obs arrays
10
+ - CognitiveEmpathy runner: `process_human_observations(human_observations:)` — perspective tracking + contagion (partner virulence 0.3, unknown 0.05)
11
+ - Valence: `:direct_address` source urgency 0.8 added to `SOURCE_URGENCY`
12
+ - MoodState: Apollo Local persistence — `dirty?`, `mark_clean!`, `to_apollo_entries`, `from_apollo`
13
+ - `PersonalityState`: new class modeling Big Five OCEAN traits with Apollo Local persistence
14
+
3
15
  ## [0.1.6] - 2026-03-30
4
16
 
5
17
  ### Changed
@@ -10,6 +10,28 @@ module Legion
10
10
  include Helpers::Constants
11
11
  include Legion::Extensions::Helpers::Lex if defined?(Legion::Extensions::Helpers::Lex)
12
12
 
13
+ def process_human_observations(human_observations: [], **)
14
+ return { processed: 0 } if human_observations.empty?
15
+
16
+ human_observations.each do |obs|
17
+ identity = obs[:identity].to_s
18
+ bond_role = obs[:bond_role] || :unknown
19
+
20
+ take_empathic_perspective(
21
+ agent_id: identity,
22
+ perspective_type: :affective,
23
+ predicted_state: { bond_role: bond_role, channel: obs[:channel] },
24
+ confidence: bond_role == :partner ? 0.7 : 0.4
25
+ )
26
+
27
+ virulence = bond_role == :partner ? 0.3 : 0.05
28
+ engine.emotional_contagion(emotion_valence: 0.5, intensity: virulence)
29
+ end
30
+
31
+ log.debug("[cognitive_empathy] process_human_observations: count=#{human_observations.size}")
32
+ { processed: human_observations.size }
33
+ end
34
+
13
35
  def take_empathic_perspective(agent_id:, perspective_type:, predicted_state:, confidence: 0.5, **)
14
36
  perspective = engine.take_perspective(
15
37
  agent_id: agent_id,
@@ -23,6 +23,7 @@ module Legion
23
23
  SOURCE_URGENCY = {
24
24
  firmware_violation: 1.0,
25
25
  human_direct: 0.9,
26
+ direct_address: 0.8,
26
27
  mesh_priority: 0.7,
27
28
  scheduled: 0.4,
28
29
  ambient: 0.1
@@ -9,14 +9,16 @@ module Legion
9
9
  class MentalModel
10
10
  attr_reader :agent_id, :believed_goal, :emotional_state, :attention_focus,
11
11
  :confidence_level, :cooperation_stance, :interaction_history,
12
- :predictions, :created_at, :updated_at
12
+ :predictions, :created_at, :updated_at, :bond_role, :channel
13
13
 
14
- def initialize(agent_id:)
14
+ def initialize(agent_id:, bond_role: :unknown, channel: nil, confidence: nil)
15
15
  @agent_id = agent_id
16
+ @bond_role = bond_role
17
+ @channel = channel
16
18
  @believed_goal = nil
17
19
  @emotional_state = :unknown
18
20
  @attention_focus = nil
19
- @confidence_level = 0.5
21
+ @confidence_level = confidence || partner_default_confidence(bond_role)
20
22
  @cooperation_stance = :unknown
21
23
  @interaction_history = []
22
24
  @predictions = []
@@ -83,6 +85,8 @@ module Legion
83
85
  def to_h
84
86
  {
85
87
  agent_id: @agent_id,
88
+ bond_role: @bond_role,
89
+ channel: @channel,
86
90
  believed_goal: @believed_goal,
87
91
  emotional_state: @emotional_state,
88
92
  attention_focus: @attention_focus,
@@ -99,6 +103,10 @@ module Legion
99
103
 
100
104
  private
101
105
 
106
+ def partner_default_confidence(bond_role)
107
+ bond_role == :partner ? 0.8 : 0.5
108
+ end
109
+
102
110
  def update_believed_goal(goal)
103
111
  @believed_goal = goal
104
112
  end
@@ -11,6 +11,7 @@ module Legion
11
11
 
12
12
  def initialize
13
13
  @models = {}
14
+ @dirty = false
14
15
  end
15
16
 
16
17
  def get(agent_id)
@@ -29,6 +30,66 @@ module Legion
29
30
  model
30
31
  end
31
32
 
33
+ def update_from_human_observation(observation)
34
+ identity = observation[:identity].to_s
35
+ bond_role = observation[:bond_role] || :unknown
36
+ channel = observation[:channel]
37
+
38
+ key = identity
39
+ model = @models[key] ||= MentalModel.new(agent_id: key, bond_role: bond_role, channel: channel)
40
+ evidence = bond_role == :partner ? 0.8 : 0.5
41
+ model.update_from_observation(
42
+ interaction_type: :human_observation,
43
+ evidence_strength: evidence,
44
+ summary: "channel=#{channel} content_type=#{observation[:content_type]} " \
45
+ "length=#{observation[:content_length]}"
46
+ )
47
+ evict_if_needed
48
+ @dirty = true
49
+ model
50
+ end
51
+
52
+ def dirty?
53
+ @dirty
54
+ end
55
+
56
+ def mark_clean!
57
+ @dirty = false
58
+ end
59
+
60
+ def to_apollo_entries
61
+ @models.values.map do |model|
62
+ data = model.to_h.merge(
63
+ created_at: model.created_at.iso8601,
64
+ updated_at: model.updated_at.iso8601
65
+ )
66
+ {
67
+ content: ::JSON.generate(data.transform_keys(&:to_s)),
68
+ tags: ['empathy', 'mental_model', model.agent_id]
69
+ }
70
+ end
71
+ end
72
+
73
+ def from_apollo(store:)
74
+ entries = store.query(tags: %w[empathy mental_model])
75
+ entries.each do |entry|
76
+ data = ::JSON.parse(entry[:content])
77
+ agent_id = data['agent_id']
78
+ next unless agent_id
79
+
80
+ bond_role = data['bond_role']&.to_sym || :unknown
81
+ channel = data['channel']&.to_sym
82
+ confidence = data['confidence_level']
83
+
84
+ model = MentalModel.new(agent_id: agent_id, bond_role: bond_role,
85
+ channel: channel, confidence: confidence)
86
+ @models[agent_id] = model
87
+ rescue ::JSON::ParserError => e
88
+ warn "[empathy] from_apollo: skipping invalid entry: #{e.message}"
89
+ next
90
+ end
91
+ end
92
+
32
93
  def predict(agent_id, scenario)
33
94
  model = get(agent_id)
34
95
  return nil unless model
@@ -10,6 +10,20 @@ 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 observe_human_observations(human_observations: [], **)
14
+ return { observed: 0, identities: [] } if human_observations.empty?
15
+
16
+ identities = human_observations.map do |obs|
17
+ model_store.update_from_human_observation(obs)
18
+ obs[:identity].to_s
19
+ end
20
+
21
+ log.debug("[empathy] human_observations: count=#{identities.size} " \
22
+ "identities=#{identities.join(',')}")
23
+
24
+ { observed: identities.size, identities: identities }
25
+ end
26
+
13
27
  def observe_agent(agent_id:, observation: {}, **)
14
28
  model = model_store.update(agent_id, observation)
15
29
  log.debug("[empathy] observed: agent=#{agent_id} emotion=#{model.emotional_state} " \
@@ -9,6 +9,8 @@ module Legion
9
9
  class MoodState
10
10
  attr_reader :current_mood, :valence, :arousal, :energy, :stability, :history, :tick_counter
11
11
 
12
+ DIRTY_THRESHOLD = 0.02
13
+
12
14
  def initialize
13
15
  @valence = 0.5
14
16
  @arousal = 0.3
@@ -17,6 +19,8 @@ module Legion
17
19
  @current_mood = :neutral
18
20
  @history = []
19
21
  @tick_counter = 0
22
+ @dirty = false
23
+ @last_persisted_valence = @valence
20
24
  end
21
25
 
22
26
  def update(inputs)
@@ -31,10 +35,42 @@ module Legion
31
35
  compute_stability
32
36
  classify_mood
33
37
  record_history
38
+ check_dirty
34
39
 
35
40
  @current_mood
36
41
  end
37
42
 
43
+ def dirty?
44
+ @dirty
45
+ end
46
+
47
+ def mark_clean!
48
+ @dirty = false
49
+ @last_persisted_valence = @valence
50
+ end
51
+
52
+ def to_apollo_entries
53
+ [{
54
+ content: ::JSON.generate(to_h.transform_keys(&:to_s).except('modulations')),
55
+ tags: %w[affect state global]
56
+ }]
57
+ end
58
+
59
+ def from_apollo(store:)
60
+ entries = store.query(tags: %w[affect state global])
61
+ return if entries.empty?
62
+
63
+ data = ::JSON.parse(entries.first[:content])
64
+ @valence = data['valence'].to_f if data['valence']
65
+ @arousal = data['arousal'].to_f if data['arousal']
66
+ @energy = data['energy'].to_f if data['energy']
67
+ @current_mood = data['current_mood']&.to_sym || @current_mood
68
+ @last_persisted_valence = @valence
69
+ @dirty = false
70
+ rescue ::JSON::ParserError => e
71
+ warn "[mood_state] from_apollo: invalid entry: #{e.message}"
72
+ end
73
+
38
74
  def modulations
39
75
  Constants::MOOD_MODULATIONS.fetch(@current_mood, Constants::MOOD_MODULATIONS[:neutral])
40
76
  end
@@ -90,6 +126,10 @@ module Legion
90
126
 
91
127
  private
92
128
 
129
+ def check_dirty
130
+ @dirty = true if (@valence - @last_persisted_valence).abs >= DIRTY_THRESHOLD
131
+ end
132
+
93
133
  def effective_alpha
94
134
  base_alpha = Constants::MOOD_ALPHA
95
135
  current_inertia = inertia
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Agentic
6
+ module Affect
7
+ # PersonalityState models the Big Five (OCEAN) personality dimensions as global
8
+ # affect modifiers. Traits persist to Apollo Local tagged ['personality', 'ocean', 'global'].
9
+ class PersonalityState
10
+ TRAITS = %i[openness conscientiousness extraversion agreeableness neuroticism].freeze
11
+ DIRTY_THRESHOLD = 0.02
12
+
13
+ attr_reader(*TRAITS)
14
+
15
+ def initialize
16
+ TRAITS.each { |t| instance_variable_set(:"@#{t}", 0.5) }
17
+ @dirty = false
18
+ @last_persisted = snapshot
19
+ end
20
+
21
+ def update_trait(trait, value)
22
+ return unless TRAITS.include?(trait)
23
+
24
+ clamped = value.to_f.clamp(0.0, 1.0)
25
+ instance_variable_set(:"@#{trait}", clamped)
26
+ @dirty = true if (clamped - @last_persisted[trait]).abs >= DIRTY_THRESHOLD
27
+ end
28
+
29
+ def dirty?
30
+ @dirty
31
+ end
32
+
33
+ def mark_clean!
34
+ @dirty = false
35
+ @last_persisted = snapshot
36
+ end
37
+
38
+ def to_apollo_entries
39
+ [{
40
+ content: ::JSON.generate(to_h.transform_keys(&:to_s)),
41
+ tags: %w[personality ocean global]
42
+ }]
43
+ end
44
+
45
+ def from_apollo(store:)
46
+ entries = store.query(tags: %w[personality ocean global])
47
+ return if entries.empty?
48
+
49
+ data = ::JSON.parse(entries.first[:content])
50
+ TRAITS.each do |trait|
51
+ val = data[trait.to_s]
52
+ instance_variable_set(:"@#{trait}", val.to_f.clamp(0.0, 1.0)) if val
53
+ end
54
+ @last_persisted = snapshot
55
+ @dirty = false
56
+ rescue ::JSON::ParserError => e
57
+ warn "[personality_state] from_apollo: invalid entry: #{e.message}"
58
+ end
59
+
60
+ def to_h
61
+ TRAITS.to_h { |t| [t, instance_variable_get(:"@#{t}")] }
62
+ end
63
+
64
+ private
65
+
66
+ def snapshot
67
+ TRAITS.to_h { |t| [t, instance_variable_get(:"@#{t}")] }
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -4,7 +4,7 @@ module Legion
4
4
  module Extensions
5
5
  module Agentic
6
6
  module Affect
7
- VERSION = '0.1.6'
7
+ VERSION = '0.1.7'
8
8
  end
9
9
  end
10
10
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative 'affect/version'
4
+ require_relative 'affect/personality_state'
4
5
  require_relative 'affect/cognitive_empathy'
5
6
  require_relative 'affect/reappraisal'
6
7
  require_relative 'affect/defusion'
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::Agentic::Affect::CognitiveEmpathy::Runners::CognitiveEmpathy do
4
+ let(:runner) do
5
+ obj = Object.new
6
+ obj.extend(described_class)
7
+ obj
8
+ end
9
+
10
+ let(:partner_obs) do
11
+ {
12
+ identity: 'esity',
13
+ bond_role: :partner,
14
+ channel: :cli,
15
+ content_type: :text,
16
+ content_length: 80,
17
+ direct_address: true,
18
+ timestamp: Time.now.utc
19
+ }
20
+ end
21
+
22
+ let(:unknown_obs) do
23
+ {
24
+ identity: 'stranger',
25
+ bond_role: :unknown,
26
+ channel: :teams,
27
+ content_type: :text,
28
+ content_length: 10,
29
+ direct_address: false,
30
+ timestamp: Time.now.utc
31
+ }
32
+ end
33
+
34
+ describe '#process_human_observations' do
35
+ it 'returns empty result for empty array' do
36
+ result = runner.process_human_observations(human_observations: [])
37
+ expect(result[:processed]).to eq(0)
38
+ end
39
+
40
+ it 'processes a partner observation and creates a perspective' do
41
+ result = runner.process_human_observations(human_observations: [partner_obs])
42
+ expect(result[:processed]).to eq(1)
43
+ end
44
+
45
+ it 'processes multiple observations' do
46
+ result = runner.process_human_observations(human_observations: [partner_obs, unknown_obs])
47
+ expect(result[:processed]).to eq(2)
48
+ end
49
+
50
+ it 'applies contagion with higher virulence for partner bond_role' do
51
+ before_level = runner.current_empathic_state[:contagion_level]
52
+ runner.process_human_observations(human_observations: [partner_obs])
53
+ after_level = runner.current_empathic_state[:contagion_level]
54
+ expect(after_level).to be >= before_level
55
+ end
56
+
57
+ it 'applies contagion with lower virulence for unknown bond_role' do
58
+ runner.process_human_observations(human_observations: [unknown_obs])
59
+ result = runner.current_empathic_state
60
+ expect(result[:contagion_level]).to be >= 0.0
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::Agentic::Affect::Emotion::Helpers::Valence do
4
+ describe 'SOURCE_URGENCY' do
5
+ it 'includes :direct_address with urgency 0.8' do
6
+ expect(described_class::SOURCE_URGENCY[:direct_address]).to eq(0.8)
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::Agentic::Affect::Empathy::Helpers::ModelStore do
4
+ subject(:store) { described_class.new }
5
+
6
+ let(:obs_partner) do
7
+ {
8
+ identity: 'esity',
9
+ bond_role: :partner,
10
+ channel: :cli,
11
+ content_type: :text,
12
+ content_length: 80,
13
+ direct_address: true,
14
+ timestamp: Time.now.utc
15
+ }
16
+ end
17
+
18
+ let(:obs_unknown) do
19
+ {
20
+ identity: 'stranger',
21
+ bond_role: :unknown,
22
+ channel: :teams,
23
+ content_type: :text,
24
+ content_length: 20,
25
+ direct_address: false,
26
+ timestamp: Time.now.utc
27
+ }
28
+ end
29
+
30
+ describe '#update_from_human_observation' do
31
+ it 'creates a model for the observed identity' do
32
+ model = store.update_from_human_observation(obs_partner)
33
+ expect(model.agent_id).to eq('esity')
34
+ end
35
+
36
+ it 'sets confidence to 0.8 for partner bond_role' do
37
+ model = store.update_from_human_observation(obs_partner)
38
+ expect(model.confidence_level).to be_within(0.05).of(0.8)
39
+ end
40
+
41
+ it 'sets confidence near 0.5 for unknown bond_role' do
42
+ model = store.update_from_human_observation(obs_unknown)
43
+ expect(model.confidence_level).to be_within(0.1).of(0.5)
44
+ end
45
+
46
+ it 'stores bond_role on the model' do
47
+ model = store.update_from_human_observation(obs_partner)
48
+ expect(model.bond_role).to eq(:partner)
49
+ end
50
+
51
+ it 'stores channel on the model' do
52
+ model = store.update_from_human_observation(obs_partner)
53
+ expect(model.channel).to eq(:cli)
54
+ end
55
+
56
+ it 'increments store size' do
57
+ store.update_from_human_observation(obs_partner)
58
+ store.update_from_human_observation(obs_unknown)
59
+ expect(store.size).to eq(2)
60
+ end
61
+ end
62
+
63
+ describe '#dirty?' do
64
+ it 'is false on new store' do
65
+ expect(store.dirty?).to be false
66
+ end
67
+
68
+ it 'is true after an update_from_human_observation' do
69
+ store.update_from_human_observation(obs_partner)
70
+ expect(store.dirty?).to be true
71
+ end
72
+
73
+ it 'is false after mark_clean!' do
74
+ store.update_from_human_observation(obs_partner)
75
+ store.mark_clean!
76
+ expect(store.dirty?).to be false
77
+ end
78
+ end
79
+
80
+ describe '#mark_clean!' do
81
+ it 'resets dirty flag' do
82
+ store.update_from_human_observation(obs_partner)
83
+ store.mark_clean!
84
+ expect(store.dirty?).to be false
85
+ end
86
+ end
87
+
88
+ describe '#to_apollo_entries' do
89
+ it 'returns empty array when store is empty' do
90
+ expect(store.to_apollo_entries).to eq([])
91
+ end
92
+
93
+ it 'returns one entry per model' do
94
+ store.update_from_human_observation(obs_partner)
95
+ store.update_from_human_observation(obs_unknown)
96
+ expect(store.to_apollo_entries.size).to eq(2)
97
+ end
98
+
99
+ it 'includes empathy, mental_model, and agent_id in tags' do
100
+ store.update_from_human_observation(obs_partner)
101
+ entry = store.to_apollo_entries.first
102
+ expect(entry[:tags]).to include('empathy', 'mental_model', 'esity')
103
+ end
104
+
105
+ it 'serializes content as a JSON string' do
106
+ store.update_from_human_observation(obs_partner)
107
+ entry = store.to_apollo_entries.first
108
+ parsed = JSON.parse(entry[:content])
109
+ expect(parsed['agent_id']).to eq('esity')
110
+ expect(parsed).to have_key('confidence_level')
111
+ end
112
+ end
113
+
114
+ describe '#from_apollo' do
115
+ it 'restores models from apollo entries' do
116
+ store.update_from_human_observation(obs_partner)
117
+ entries = store.to_apollo_entries
118
+
119
+ new_store = described_class.new
120
+ apollo_stub = double('apollo_local')
121
+ allow(apollo_stub).to receive(:query).and_return(entries.map { |e| { content: e[:content] } })
122
+
123
+ new_store.from_apollo(store: apollo_stub)
124
+ expect(new_store.size).to eq(1)
125
+ expect(new_store.get('esity')).not_to be_nil
126
+ end
127
+
128
+ it 'restores bond_role on loaded model' do
129
+ store.update_from_human_observation(obs_partner)
130
+ entries = store.to_apollo_entries
131
+
132
+ new_store = described_class.new
133
+ apollo_stub = double('apollo_local')
134
+ allow(apollo_stub).to receive(:query).and_return(entries.map { |e| { content: e[:content] } })
135
+
136
+ new_store.from_apollo(store: apollo_stub)
137
+ expect(new_store.get('esity').bond_role).to eq(:partner)
138
+ end
139
+
140
+ it 'handles empty apollo result gracefully' do
141
+ apollo_stub = double('apollo_local')
142
+ allow(apollo_stub).to receive(:query).and_return([])
143
+ expect { store.from_apollo(store: apollo_stub) }.not_to raise_error
144
+ expect(store.size).to eq(0)
145
+ end
146
+
147
+ it 'skips entries with invalid JSON content' do
148
+ apollo_stub = double('apollo_local')
149
+ allow(apollo_stub).to receive(:query).and_return([{ content: 'not_json' }])
150
+ expect { store.from_apollo(store: apollo_stub) }.not_to raise_error
151
+ end
152
+ end
153
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::Agentic::Affect::Empathy::Runners::Empathy do
4
+ let(:client) { Legion::Extensions::Agentic::Affect::Empathy::Client.new }
5
+
6
+ let(:partner_obs) do
7
+ {
8
+ identity: 'esity',
9
+ bond_role: :partner,
10
+ channel: :cli,
11
+ content_type: :text,
12
+ content_length: 50,
13
+ direct_address: true,
14
+ timestamp: Time.now.utc
15
+ }
16
+ end
17
+
18
+ let(:known_obs) do
19
+ {
20
+ identity: 'alice',
21
+ bond_role: :known,
22
+ channel: :teams,
23
+ content_type: :text,
24
+ content_length: 20,
25
+ direct_address: false,
26
+ timestamp: Time.now.utc
27
+ }
28
+ end
29
+
30
+ describe '#observe_human_observations' do
31
+ it 'returns empty result for empty array' do
32
+ result = client.observe_human_observations(human_observations: [])
33
+ expect(result[:observed]).to eq(0)
34
+ end
35
+
36
+ it 'processes a single partner observation' do
37
+ result = client.observe_human_observations(human_observations: [partner_obs])
38
+ expect(result[:observed]).to eq(1)
39
+ expect(result[:identities]).to include('esity')
40
+ end
41
+
42
+ it 'processes multiple observations' do
43
+ result = client.observe_human_observations(human_observations: [partner_obs, known_obs])
44
+ expect(result[:observed]).to eq(2)
45
+ expect(result[:identities]).to include('esity', 'alice')
46
+ end
47
+
48
+ it 'creates mental models for each identity' do
49
+ client.observe_human_observations(human_observations: [partner_obs])
50
+ model = client.model_store.get('esity')
51
+ expect(model).not_to be_nil
52
+ end
53
+
54
+ it 'sets higher confidence for partner bond_role' do
55
+ client.observe_human_observations(human_observations: [partner_obs])
56
+ model = client.model_store.get('esity')
57
+ expect(model.confidence_level).to be > 0.7
58
+ end
59
+
60
+ it 'marks store as dirty after observations' do
61
+ client.observe_human_observations(human_observations: [partner_obs])
62
+ expect(client.model_store.dirty?).to be true
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::Agentic::Affect::Mood::Helpers::MoodState do
4
+ subject(:state) { described_class.new }
5
+
6
+ describe '#dirty?' do
7
+ it 'is false on a fresh state' do
8
+ expect(state.dirty?).to be false
9
+ end
10
+
11
+ it 'is true after an update that crosses the update interval' do
12
+ Legion::Extensions::Agentic::Affect::Mood::Helpers::Constants::UPDATE_INTERVAL.times do
13
+ state.update(valence: 0.8, arousal: 0.2, energy: 0.7)
14
+ end
15
+ expect(state.dirty?).to be true
16
+ end
17
+ end
18
+
19
+ describe '#mark_clean!' do
20
+ it 'resets dirty flag' do
21
+ Legion::Extensions::Agentic::Affect::Mood::Helpers::Constants::UPDATE_INTERVAL.times do
22
+ state.update(valence: 0.8, arousal: 0.2, energy: 0.7)
23
+ end
24
+ state.mark_clean!
25
+ expect(state.dirty?).to be false
26
+ end
27
+ end
28
+
29
+ describe '#to_apollo_entries' do
30
+ it 'returns an array with a single entry' do
31
+ entries = state.to_apollo_entries
32
+ expect(entries).to be_an(Array)
33
+ expect(entries.size).to eq(1)
34
+ end
35
+
36
+ it 'tags the entry with affect, state, and global' do
37
+ entry = state.to_apollo_entries.first
38
+ expect(entry[:tags]).to include('affect', 'state', 'global')
39
+ end
40
+
41
+ it 'serializes mood, valence, and arousal in content' do
42
+ entry = state.to_apollo_entries.first
43
+ parsed = JSON.parse(entry[:content])
44
+ expect(parsed).to have_key('current_mood')
45
+ expect(parsed).to have_key('valence')
46
+ expect(parsed).to have_key('arousal')
47
+ expect(parsed).to have_key('energy')
48
+ end
49
+ end
50
+
51
+ describe '#from_apollo' do
52
+ it 'restores valence, arousal, and energy from stored entry' do
53
+ # Drive state to a non-default value
54
+ 20.times do
55
+ Legion::Extensions::Agentic::Affect::Mood::Helpers::Constants::UPDATE_INTERVAL.times do
56
+ state.update(valence: 0.9, arousal: 0.8, energy: 0.7)
57
+ end
58
+ end
59
+ entries = state.to_apollo_entries
60
+
61
+ new_state = described_class.new
62
+ apollo_stub = double('apollo_local')
63
+ allow(apollo_stub).to receive(:query).and_return(entries.map { |e| { content: e[:content] } })
64
+
65
+ new_state.from_apollo(store: apollo_stub)
66
+ expect(new_state.valence).to be_within(0.1).of(state.valence)
67
+ end
68
+
69
+ it 'handles empty apollo result gracefully' do
70
+ apollo_stub = double('apollo_local')
71
+ allow(apollo_stub).to receive(:query).and_return([])
72
+ expect { state.from_apollo(store: apollo_stub) }.not_to raise_error
73
+ end
74
+
75
+ it 'handles invalid JSON gracefully' do
76
+ apollo_stub = double('apollo_local')
77
+ allow(apollo_stub).to receive(:query).and_return([{ content: 'bad_json{' }])
78
+ expect { state.from_apollo(store: apollo_stub) }.not_to raise_error
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ RSpec.describe Legion::Extensions::Agentic::Affect::PersonalityState do
4
+ subject(:ps) { described_class.new }
5
+
6
+ describe '#initialize' do
7
+ it 'starts with neutral OCEAN traits at 0.5' do
8
+ expect(ps.openness).to eq(0.5)
9
+ expect(ps.conscientiousness).to eq(0.5)
10
+ expect(ps.extraversion).to eq(0.5)
11
+ expect(ps.agreeableness).to eq(0.5)
12
+ expect(ps.neuroticism).to eq(0.5)
13
+ end
14
+
15
+ it 'is not dirty initially' do
16
+ expect(ps.dirty?).to be false
17
+ end
18
+ end
19
+
20
+ describe '#update_trait' do
21
+ it 'updates a single trait' do
22
+ ps.update_trait(:openness, 0.8)
23
+ expect(ps.openness).to be_within(0.01).of(0.8)
24
+ end
25
+
26
+ it 'marks dirty after a significant change' do
27
+ ps.update_trait(:openness, 0.9)
28
+ expect(ps.dirty?).to be true
29
+ end
30
+
31
+ it 'does not mark dirty for a tiny change below threshold' do
32
+ ps.update_trait(:openness, 0.501)
33
+ expect(ps.dirty?).to be false
34
+ end
35
+
36
+ it 'clamps values to [0.0, 1.0]' do
37
+ ps.update_trait(:openness, 1.5)
38
+ expect(ps.openness).to eq(1.0)
39
+ ps.update_trait(:openness, -0.5)
40
+ expect(ps.openness).to eq(0.0)
41
+ end
42
+ end
43
+
44
+ describe '#mark_clean!' do
45
+ it 'resets dirty flag' do
46
+ ps.update_trait(:openness, 0.9)
47
+ ps.mark_clean!
48
+ expect(ps.dirty?).to be false
49
+ end
50
+ end
51
+
52
+ describe '#to_apollo_entries' do
53
+ it 'returns one entry' do
54
+ entries = ps.to_apollo_entries
55
+ expect(entries.size).to eq(1)
56
+ end
57
+
58
+ it 'tags the entry with personality, ocean, and global' do
59
+ entry = ps.to_apollo_entries.first
60
+ expect(entry[:tags]).to include('personality', 'ocean', 'global')
61
+ end
62
+
63
+ it 'serializes all 5 OCEAN traits in content' do
64
+ entry = ps.to_apollo_entries.first
65
+ parsed = JSON.parse(entry[:content])
66
+ expect(parsed).to have_key('openness')
67
+ expect(parsed).to have_key('conscientiousness')
68
+ expect(parsed).to have_key('extraversion')
69
+ expect(parsed).to have_key('agreeableness')
70
+ expect(parsed).to have_key('neuroticism')
71
+ end
72
+ end
73
+
74
+ describe '#from_apollo' do
75
+ it 'restores OCEAN traits from stored entry' do
76
+ ps.update_trait(:openness, 0.9)
77
+ ps.update_trait(:neuroticism, 0.2)
78
+ entries = ps.to_apollo_entries
79
+
80
+ new_ps = described_class.new
81
+ apollo_stub = double('apollo_local')
82
+ allow(apollo_stub).to receive(:query).and_return(entries.map { |e| { content: e[:content] } })
83
+
84
+ new_ps.from_apollo(store: apollo_stub)
85
+ expect(new_ps.openness).to eq(0.9)
86
+ expect(new_ps.neuroticism).to eq(0.2)
87
+ end
88
+
89
+ it 'handles empty apollo result gracefully' do
90
+ apollo_stub = double('apollo_local')
91
+ allow(apollo_stub).to receive(:query).and_return([])
92
+ expect { ps.from_apollo(store: apollo_stub) }.not_to raise_error
93
+ end
94
+
95
+ it 'handles invalid JSON gracefully' do
96
+ apollo_stub = double('apollo_local')
97
+ allow(apollo_stub).to receive(:query).and_return([{ content: 'bad{json' }])
98
+ expect { ps.from_apollo(store: apollo_stub) }.not_to raise_error
99
+ end
100
+ end
101
+
102
+ describe '#to_h' do
103
+ it 'returns hash of all 5 traits' do
104
+ h = ps.to_h
105
+ expect(h.keys).to include(:openness, :conscientiousness, :extraversion, :agreeableness, :neuroticism)
106
+ end
107
+ end
108
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lex-agentic-affect
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.6
4
+ version: 0.1.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -240,6 +240,7 @@ files:
240
240
  - lib/legion/extensions/agentic/affect/motivation/helpers/motivation_store.rb
241
241
  - lib/legion/extensions/agentic/affect/motivation/runners/motivation.rb
242
242
  - lib/legion/extensions/agentic/affect/motivation/version.rb
243
+ - lib/legion/extensions/agentic/affect/personality_state.rb
243
244
  - lib/legion/extensions/agentic/affect/reappraisal.rb
244
245
  - lib/legion/extensions/agentic/affect/reappraisal/actors/auto_regulate.rb
245
246
  - lib/legion/extensions/agentic/affect/reappraisal/client.rb
@@ -294,6 +295,7 @@ files:
294
295
  - spec/legion/extensions/agentic/affect/cognitive_empathy/client_spec.rb
295
296
  - spec/legion/extensions/agentic/affect/cognitive_empathy/helpers/empathy_engine_spec.rb
296
297
  - spec/legion/extensions/agentic/affect/cognitive_empathy/helpers/perspective_spec.rb
298
+ - spec/legion/extensions/agentic/affect/cognitive_empathy/runners/cognitive_empathy_human_obs_spec.rb
297
299
  - spec/legion/extensions/agentic/affect/cognitive_empathy/runners/cognitive_empathy_spec.rb
298
300
  - spec/legion/extensions/agentic/affect/contagion/client_spec.rb
299
301
  - spec/legion/extensions/agentic/affect/contagion/helpers/constants_spec.rb
@@ -309,13 +311,16 @@ files:
309
311
  - spec/legion/extensions/agentic/affect/emotion/client_spec.rb
310
312
  - spec/legion/extensions/agentic/affect/emotion/helpers/baseline_spec.rb
311
313
  - spec/legion/extensions/agentic/affect/emotion/helpers/momentum_spec.rb
314
+ - spec/legion/extensions/agentic/affect/emotion/helpers/valence_direct_address_spec.rb
312
315
  - spec/legion/extensions/agentic/affect/emotion/helpers/valence_spec.rb
313
316
  - spec/legion/extensions/agentic/affect/emotion/runners/gut_spec.rb
314
317
  - spec/legion/extensions/agentic/affect/emotion/runners/valence_spec.rb
315
318
  - spec/legion/extensions/agentic/affect/empathy/client_spec.rb
316
319
  - spec/legion/extensions/agentic/affect/empathy/helpers/constants_spec.rb
317
320
  - spec/legion/extensions/agentic/affect/empathy/helpers/mental_model_spec.rb
321
+ - spec/legion/extensions/agentic/affect/empathy/helpers/model_store_apollo_spec.rb
318
322
  - spec/legion/extensions/agentic/affect/empathy/helpers/model_store_spec.rb
323
+ - spec/legion/extensions/agentic/affect/empathy/runners/empathy_human_obs_spec.rb
319
324
  - spec/legion/extensions/agentic/affect/empathy/runners/empathy_spec.rb
320
325
  - spec/legion/extensions/agentic/affect/fatigue/client_spec.rb
321
326
  - spec/legion/extensions/agentic/affect/fatigue/helpers/constants_spec.rb
@@ -332,6 +337,7 @@ files:
332
337
  - spec/legion/extensions/agentic/affect/interoception/runners/interoception_spec.rb
333
338
  - spec/legion/extensions/agentic/affect/mood/client_spec.rb
334
339
  - spec/legion/extensions/agentic/affect/mood/helpers/constants_spec.rb
340
+ - spec/legion/extensions/agentic/affect/mood/helpers/mood_state_apollo_spec.rb
335
341
  - spec/legion/extensions/agentic/affect/mood/helpers/mood_state_spec.rb
336
342
  - spec/legion/extensions/agentic/affect/mood/runners/mood_spec.rb
337
343
  - spec/legion/extensions/agentic/affect/motivation/client_spec.rb
@@ -339,6 +345,7 @@ files:
339
345
  - spec/legion/extensions/agentic/affect/motivation/helpers/drive_state_spec.rb
340
346
  - spec/legion/extensions/agentic/affect/motivation/helpers/motivation_store_spec.rb
341
347
  - spec/legion/extensions/agentic/affect/motivation/runners/motivation_spec.rb
348
+ - spec/legion/extensions/agentic/affect/personality_state_spec.rb
342
349
  - spec/legion/extensions/agentic/affect/reappraisal/actors/auto_regulate_spec.rb
343
350
  - spec/legion/extensions/agentic/affect/reappraisal/client_spec.rb
344
351
  - spec/legion/extensions/agentic/affect/reappraisal/helpers/constants_spec.rb