lex-agentic-memory 0.1.34 → 0.1.35

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: 606b711547732f1f697b57ce2392509c2774397c725b51f87baeaee591cf2ae2
4
- data.tar.gz: f9f704beca9f4f33c8d71ad54627ef400561819898aeebf06c0097a0c0a535b1
3
+ metadata.gz: bb940d62021d4a86ed5de171d6b275c9287a9ca727efadd3c60c3a87ca0f921e
4
+ data.tar.gz: b02842d4a5939e436d7ae4f72cca295839061f5fa4cdbf9b47736f37e462e76e
5
5
  SHA512:
6
- metadata.gz: 35f855a01efbfb5b29f23fe12f92e89082a3a4c8001b149dd16f6ccd648bc9d67a699755230e5b08c4a86f1147522f86b71d5fea3e31f3842448c20e4af3d094
7
- data.tar.gz: 8e9afdf79c62437fefe856a61a0432d1574a7fba7dadab51c4a6b19a0e8a6d6a3658a19fbfa725d5cdb5c4c8de300169818aeee15c1e204e9cf7ff15a9cf12d3
6
+ metadata.gz: e28e286f59952562c8c00ad37cb06f84914f2b93af864cb95a1d9904c218dc72d28bb09a4df049c418f5a66df752a199171c0e553e684c234ae888ab73be24a8
7
+ data.tar.gz: 12febef87343cde5c4ca8f372c62a6f7d5b053a47ad5362710ee54224be60ceb899f7f0f32eeb65cc9474ebe5b47535731e2f6a3e83a89f3d315c6780774ea77
data/CHANGELOG.md CHANGED
@@ -5,6 +5,11 @@
5
5
  - Deferred GAIA heartbeat maintenance no longer initializes the shared trace store just to return a deferred summary, avoiding idle local trace table scans.
6
6
  - `Store#parse_db_content` now returns bracket-prefixed raw trace/log text without JSON parse attempts or malformed JSON debug noise while still parsing real JSON objects and arrays.
7
7
 
8
+ ## [0.1.35] - 2026-05-15
9
+ ### Fixed
10
+ - Normalize trace affect fields before persisting through in-memory, cache, local SQLite, and Postgres stores so string-backed values do not crash or silently collapse to `0.0`.
11
+ - Preserve numeric `emotional_valence` across local trace persistence and normalize legacy structured payloads when they are reloaded.
12
+
8
13
  ## [0.1.33] - 2026-05-07
9
14
  ### Fixed
10
15
  - `PostgresStore#parse_json_or_raw` no longer attempts JSON parse on plain-text content — checks for `{`/`[` prefix before parsing, eliminating ERROR log spam during bulk reads with mixed content types
@@ -164,9 +164,9 @@ module Legion
164
164
  site.survey.merge(
165
165
  depth_progress: depth_progress(site),
166
166
  artifact_types: site.artifacts_found
167
- .each_with_object(Hash.new(0)) do |a, h|
168
- h[a.artifact_type] += 1
169
- end
167
+ .each_with_object(Hash.new(0)) do |a, h|
168
+ h[a.artifact_type] += 1
169
+ end
170
170
  )
171
171
  end
172
172
 
@@ -38,11 +38,12 @@ module Legion
38
38
  end
39
39
 
40
40
  def store(trace)
41
+ normalized_trace = Helpers::Trace.normalize_trace_affect(trace)
41
42
  @mutex.synchronize do
42
- @traces[trace[:trace_id]] = trace
43
- @dirty_ids << trace[:trace_id]
43
+ @traces[normalized_trace[:trace_id]] = normalized_trace
44
+ @dirty_ids << normalized_trace[:trace_id]
44
45
  end
45
- trace[:trace_id]
46
+ normalized_trace[:trace_id]
46
47
  end
47
48
 
48
49
  def get(trace_id)
@@ -27,15 +27,16 @@ module Legion
27
27
  def store(trace)
28
28
  return nil unless db_ready?
29
29
 
30
- row = serialize_trace(trace)
30
+ normalized_trace = Helpers::Trace.normalize_trace_affect(trace)
31
+ row = serialize_trace(normalized_trace)
31
32
  ds = db[TRACES_TABLE]
32
33
  if db.adapter_scheme == :mysql2
33
34
  ds.insert_conflict(update: row.except(:trace_id)).insert(row)
34
35
  else
35
36
  ds.insert_conflict(target: :trace_id, update: row.except(:trace_id)).insert(row)
36
37
  end
37
- HotTier.cache_trace(trace, tenant_id: @tenant_id, agent_id: @agent_id) if HotTier.available?
38
- trace[:trace_id]
38
+ HotTier.cache_trace(normalized_trace, tenant_id: @tenant_id, agent_id: @agent_id) if HotTier.available?
39
+ normalized_trace[:trace_id]
39
40
  rescue StandardError => e
40
41
  log_warn("store failed: #{e.message}")
41
42
  nil
@@ -299,7 +300,7 @@ module Legion
299
300
  tags = trace[:domain_tags]
300
301
  assocs = trace[:associated_traces]
301
302
  conf = trace[:confidence]
302
- ev = trace[:emotional_valence]
303
+ ev = Helpers::Trace.normalize_emotional_valence(trace[:emotional_valence])
303
304
 
304
305
  {
305
306
  trace_id: trace[:trace_id],
@@ -314,8 +315,8 @@ module Legion
314
315
  strength: trace[:strength],
315
316
  peak_strength: trace[:peak_strength],
316
317
  base_decay_rate: trace[:base_decay_rate],
317
- emotional_valence: ev.is_a?(Numeric) ? ev.to_f : 0.0,
318
- emotional_intensity: trace[:emotional_intensity],
318
+ emotional_valence: ev,
319
+ emotional_intensity: Helpers::Trace.normalize_emotional_intensity(trace[:emotional_intensity]),
319
320
  origin: sanitize_pg_string(trace[:origin].to_s),
320
321
  source_agent_id: sanitize_pg_string(trace[:source_agent_id]),
321
322
  storage_tier: sanitize_pg_string(trace[:storage_tier].to_s),
@@ -29,7 +29,7 @@ module Legion
29
29
  end
30
30
 
31
31
  def store(trace)
32
- persisted_trace = trace.dup
32
+ persisted_trace = Helpers::Trace.normalize_trace_affect(trace)
33
33
  persisted_trace[:partition_id] ||= @partition_id
34
34
  @mutex.synchronize do
35
35
  @traces_dirty = true if @traces[persisted_trace[:trace_id]] != persisted_trace
@@ -120,7 +120,7 @@ module Legion
120
120
  snapshot = Array(traces).each_with_object({}) do |trace, memo|
121
121
  next unless trace.is_a?(Hash) && trace[:trace_id]
122
122
 
123
- restored = trace.dup
123
+ restored = Helpers::Trace.normalize_trace_affect(trace)
124
124
  restored[:partition_id] ||= @partition_id
125
125
  memo[restored[:trace_id]] = restored
126
126
  end
@@ -294,7 +294,7 @@ module Legion
294
294
  strength: trace[:strength],
295
295
  peak_strength: trace[:peak_strength],
296
296
  base_decay_rate: trace[:base_decay_rate],
297
- emotional_valence: trace[:emotional_valence].is_a?(Hash) ? ::JSON.generate(trace[:emotional_valence]) : nil,
297
+ emotional_valence: Helpers::Trace.normalize_emotional_valence(trace[:emotional_valence]).to_s,
298
298
  emotional_intensity: trace[:emotional_intensity],
299
299
  domain_tags: trace[:domain_tags].is_a?(Array) ? ::JSON.generate(trace[:domain_tags]) : nil,
300
300
  origin: trace[:origin].to_s,
@@ -325,7 +325,7 @@ module Legion
325
325
  strength: row[:strength],
326
326
  peak_strength: row[:peak_strength],
327
327
  base_decay_rate: row[:base_decay_rate],
328
- emotional_valence: parse_db_json(row[:emotional_valence], 'emotional_valence', symbolize: true) { 0.0 },
328
+ emotional_valence: deserialize_emotional_valence(row[:emotional_valence]),
329
329
  emotional_intensity: row[:emotional_intensity],
330
330
  domain_tags: parse_db_json(row[:domain_tags], 'domain_tags') { [] },
331
331
  origin: row[:origin]&.to_sym,
@@ -364,6 +364,12 @@ module Legion
364
364
  %w[{ [ " ]].include?(first_array_value)
365
365
  end
366
366
 
367
+ def deserialize_emotional_valence(raw)
368
+ return 0.0 if raw.nil? || raw.to_s.strip.empty?
369
+
370
+ Helpers::Trace.normalize_emotional_valence(raw)
371
+ end
372
+
367
373
  def parse_db_json(raw, field, symbolize: false, &default)
368
374
  return default&.call if raw.nil? || raw.to_s.strip.empty?
369
375
 
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'json'
3
4
  require 'securerandom'
4
5
 
5
6
  module Legion
@@ -48,6 +49,7 @@ module Legion
48
49
  ASSOCIATION_BONUS = 0.15 # bonus for Hebbian-associated traces
49
50
  MAX_ASSOCIATIONS = 20 # max Hebbian links per trace
50
51
  COACTIVATION_THRESHOLD = 3 # co-activations before Hebbian link forms
52
+ VALENCE_SCALAR_KEYS = %i[valence emotional_valence sentiment polarity score].freeze
51
53
 
52
54
  module_function
53
55
 
@@ -59,6 +61,10 @@ module Legion
59
61
  raise ArgumentError, "invalid origin: #{origin}" unless ORIGINS.include?(origin)
60
62
 
61
63
  now = Time.now.utc
64
+ emotional_context = normalize_trace_affect(
65
+ emotional_valence: emotional_valence,
66
+ emotional_intensity: emotional_intensity
67
+ )
62
68
 
63
69
  {
64
70
  trace_id: SecureRandom.uuid,
@@ -68,8 +74,8 @@ module Legion
68
74
  strength: STARTING_STRENGTHS[type],
69
75
  peak_strength: STARTING_STRENGTHS[type],
70
76
  base_decay_rate: BASE_DECAY_RATES[type],
71
- emotional_valence: emotional_valence.clamp(-1.0, 1.0),
72
- emotional_intensity: emotional_intensity.clamp(0.0, 1.0),
77
+ emotional_valence: emotional_context[:emotional_valence],
78
+ emotional_intensity: emotional_context[:emotional_intensity],
73
79
  domain_tags: Array(domain_tags),
74
80
  origin: origin,
75
81
  source_agent_id: source_agent_id,
@@ -98,11 +104,89 @@ module Legion
98
104
  true
99
105
  end
100
106
 
107
+ def normalize_trace_affect(trace)
108
+ normalized = trace.dup
109
+ normalized[:emotional_valence] = normalize_emotional_valence(normalized[:emotional_valence])
110
+ normalized[:emotional_intensity] = normalize_emotional_intensity(normalized[:emotional_intensity])
111
+ normalized
112
+ end
113
+
114
+ def normalize_emotional_valence(value)
115
+ normalize_scalar(value, min: -1.0, max: 1.0, hash_keys: VALENCE_SCALAR_KEYS)
116
+ end
117
+
118
+ def normalize_emotional_intensity(value)
119
+ normalize_scalar(value, min: 0.0, max: 1.0)
120
+ end
121
+
101
122
  def default_partition_id
102
123
  Legion::Settings.dig(:agent, :id) || 'default'
103
124
  rescue StandardError => _e
104
125
  'default'
105
126
  end
127
+
128
+ def normalize_scalar(value, min:, max:, hash_keys: [])
129
+ case value
130
+ when Numeric
131
+ value.to_f.clamp(min, max)
132
+ when String
133
+ normalize_string_scalar(value, min: min, max: max, hash_keys: hash_keys)
134
+ when Hash
135
+ normalize_hash_scalar(value, min: min, max: max, hash_keys: hash_keys)
136
+ else
137
+ 0.0
138
+ end
139
+ end
140
+
141
+ def normalize_string_scalar(value, min:, max:, hash_keys:)
142
+ stripped = value.strip
143
+ return 0.0 if stripped.empty?
144
+
145
+ Float(stripped).clamp(min, max)
146
+ rescue ArgumentError, TypeError => e
147
+ Legion::Logging.debug("[memory][trace] normalize_string_scalar fallback: #{e.message}")
148
+ parsed = parse_structured_scalar(stripped)
149
+ return normalize_scalar(parsed, min: min, max: max, hash_keys: hash_keys) if parsed
150
+
151
+ 0.0
152
+ end
153
+
154
+ def normalize_hash_scalar(value, min:, max:, hash_keys:)
155
+ symbolized = symbolize_keys(value)
156
+ scalar_value = hash_keys.lazy.map { |key| symbolized[key] }.find { |candidate| scalar_candidate?(candidate) }
157
+ return normalize_scalar(scalar_value, min: min, max: max, hash_keys: hash_keys) if scalar_candidate?(scalar_value)
158
+
159
+ numeric_values = symbolized.values.select { |candidate| scalar_candidate?(candidate) }
160
+ return normalize_scalar(numeric_values.first, min: min, max: max, hash_keys: hash_keys) if numeric_values.one?
161
+
162
+ 0.0
163
+ end
164
+
165
+ def parse_structured_scalar(value)
166
+ return unless value.start_with?('{', '[')
167
+
168
+ ::JSON.parse(value)
169
+ rescue ::JSON::ParserError => e
170
+ Legion::Logging.debug("[memory][trace] parse_structured_scalar ignored: #{e.message}")
171
+ nil
172
+ end
173
+
174
+ def scalar_candidate?(value)
175
+ value.is_a?(Numeric) || value.is_a?(String)
176
+ end
177
+
178
+ def symbolize_keys(value)
179
+ case value
180
+ when Hash
181
+ value.each_with_object({}) do |(key, nested), memo|
182
+ memo[key.to_sym] = symbolize_keys(nested)
183
+ end
184
+ when Array
185
+ value.map { |nested| symbolize_keys(nested) }
186
+ else
187
+ value
188
+ end
189
+ end
106
190
  end
107
191
  end
108
192
  end
@@ -4,7 +4,7 @@ module Legion
4
4
  module Extensions
5
5
  module Agentic
6
6
  module Memory
7
- VERSION = '0.1.34'
7
+ VERSION = '0.1.35'
8
8
  end
9
9
  end
10
10
  end
@@ -238,6 +238,20 @@ RSpec.describe Legion::Extensions::Agentic::Memory::Trace::Helpers::PostgresStor
238
238
  end
239
239
  end
240
240
 
241
+ describe 'emotional valence normalization' do
242
+ it 'normalizes string-backed affect fields before persisting' do
243
+ trace = trace_helper.new_trace(type: :episodic, content_payload: { event: 'partner ping' })
244
+ trace[:emotional_valence] = '0.7'
245
+ trace[:emotional_intensity] = '0.9'
246
+
247
+ store.store(trace)
248
+
249
+ row = db[:memory_traces].where(trace_id: trace[:trace_id]).first
250
+ expect(row[:emotional_valence]).to be_within(0.001).of(0.7)
251
+ expect(row[:emotional_intensity]).to be_within(0.001).of(0.9)
252
+ end
253
+ end
254
+
241
255
  # --- retrieve_by_type ---
242
256
 
243
257
  describe '#retrieve_by_type' do
@@ -36,6 +36,30 @@ RSpec.describe Legion::Extensions::Agentic::Memory::Trace::Helpers::Trace do
36
36
  expect(trace[:emotional_intensity]).to eq(0.0)
37
37
  end
38
38
 
39
+ it 'normalizes string and structured emotional values safely' do
40
+ trace = described_class.new_trace(
41
+ type: :episodic,
42
+ content_payload: { event: 'partner ping' },
43
+ emotional_valence: '{:urgency=>0.6, :importance=>0.8}',
44
+ emotional_intensity: '0.75'
45
+ )
46
+
47
+ expect(trace[:emotional_valence]).to eq(0.0)
48
+ expect(trace[:emotional_intensity]).to eq(0.75)
49
+ end
50
+
51
+ it 'extracts scalar valence from hash payloads when an explicit valence key exists' do
52
+ trace = described_class.new_trace(
53
+ type: :semantic,
54
+ content_payload: { fact: 'test' },
55
+ emotional_valence: { valence: 0.4 },
56
+ emotional_intensity: '2.0'
57
+ )
58
+
59
+ expect(trace[:emotional_valence]).to eq(0.4)
60
+ expect(trace[:emotional_intensity]).to eq(1.0)
61
+ end
62
+
39
63
  it 'rejects invalid trace types' do
40
64
  expect do
41
65
  described_class.new_trace(type: :bogus, content_payload: {})
@@ -249,12 +249,12 @@ RSpec.describe 'lex-memory local SQLite persistence' do
249
249
  expect(fresh.get(episodic_trace[:trace_id])).not_to be_nil
250
250
  end
251
251
 
252
- it 'honors symbolize parsing for JSON hash fields' do
252
+ it 'restores numeric emotional_valence values from local persistence' do
253
253
  trace = trace_helper.new_trace(
254
254
  type: :semantic,
255
255
  content_payload: { fact: 'symbolized' },
256
256
  domain_tags: ['json'],
257
- emotional_valence: { joy: 0.8 }
257
+ emotional_valence: 0.8
258
258
  )
259
259
  store.store(trace)
260
260
  store.save_to_local
@@ -262,8 +262,25 @@ RSpec.describe 'lex-memory local SQLite persistence' do
262
262
  fresh = Legion::Extensions::Agentic::Memory::Trace::Helpers::Store.new
263
263
  restored = fresh.get(trace[:trace_id])
264
264
 
265
- expect(restored[:emotional_valence]).to include(joy: 0.8)
266
- expect(restored[:emotional_valence]).not_to have_key('joy')
265
+ expect(restored[:emotional_valence]).to be_within(0.001).of(0.8)
266
+ end
267
+
268
+ it 'normalizes legacy JSON emotional_valence payloads on load' do
269
+ trace = trace_helper.new_trace(
270
+ type: :semantic,
271
+ content_payload: { fact: 'legacy' },
272
+ domain_tags: ['json']
273
+ )
274
+ store.store(trace)
275
+ store.save_to_local
276
+
277
+ db = Legion::Data::Local.connection
278
+ db[:memory_traces].where(trace_id: trace[:trace_id]).update(emotional_valence: '{"valence":0.8}')
279
+
280
+ fresh = Legion::Extensions::Agentic::Memory::Trace::Helpers::Store.new
281
+ restored = fresh.get(trace[:trace_id])
282
+
283
+ expect(restored[:emotional_valence]).to be_within(0.001).of(0.8)
267
284
  end
268
285
 
269
286
  it 'restores associations from the database into a fresh store' do
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lex-agentic-memory
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.34
4
+ version: 0.1.35
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity