lex-agentic-memory 0.1.40 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: d111b8e5f52156c7060509c6d50d9c4ffe3f7d02108e7f1fcb6a5ba03f1982d8
4
- data.tar.gz: 52db96d9807a927c29a097c7a0a47b50d63c43069e657bc7fdad57119a09152a
3
+ metadata.gz: a82760274cd082ae6c5b20be2506a95b6a83428ec6e2a7c03340108d8b01a3b5
4
+ data.tar.gz: 1c95b9324954ea83899f27bc49d989405d22c21dd3a83f26248c87c8e01aba0a
5
5
  SHA512:
6
- metadata.gz: a538816df808c0bb5da844ababdbb8dca9f02516803c4ec8791b7228f4c70c96269be9232fe69be56e107ac6a08b4d82e57bb3a7931074742420dccae8f22c56
7
- data.tar.gz: a8c41f78835e8e044ce289c0298727b1e4381598366882715836a2bafd591c8bf88223a388ca4f26cd1be2e9fac2a96a34b6a64f3a3b03c3d2501357928d5211
6
+ metadata.gz: 5ed2b7b49558c903d03400562dd3bef0cd90aec5e65f19e520f2b82810f74498fcda622445ec632778f36b9275f019a462faa4ee476dd6e6b32e2848d0fd453c
7
+ data.tar.gz: 9923a37c18191b9b6e6bcccbd9765e800d50fb74f13d053c3262599141cbe88060bb494b69f30e6c592d604da1694902fefc75c1c9a584e6786bdee7ccf23a18
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.2.0] - 2026-10-29
4
+ ### Changed
5
+ - Partition fallback-cache traces, indexes, and associations by agent. Ambiguous legacy global cache history is not hydrated; existing global keys are left untouched and a warning explains the cold start.
6
+ - Do not roll cache-backed deployments back to unpartitioned readers without controlled cache recovery: old readers cannot see new partitioned writes and can expose surviving global history.
7
+ ### Fixed
8
+ - Apply trace, domain, and identity selectors before ranking, limiting, or reinforcement; explicit empty candidates select nothing and duplicate candidates reinforce only once.
9
+ - Preserve ranked `{ trace:, score: }` results and legacy omitted-identity behavior. Explicit anonymous identity excludes partner/owner-scoped traces.
10
+ - Derive default decay age from persisted last reinforcement time using the existing 60-second interval, preserving explicit age overrides and preventing maintenance from increasing strength.
11
+ - Persist deletion of surviving association references and restore scoped coactivation progress across cache restarts.
12
+
13
+ ## [0.1.41] - 2026-07-22
14
+ ### Added
15
+ - `Runners::Traces#erase_partner!(identity:)` — deletes all traces tagged `partner:<identity>` and `owner:<identity>`; returns `{ erased: true, identity:, count: }`. Prep for H8 death protocol cascade (Phase 4.5 contract gate gap).
16
+
3
17
  ## [0.1.40] - 2026-06-17
4
18
  ### Fixed
5
19
  - **Critical performance fix:** Trace store `snapshot_dirty_state` no longer serializes ALL traces to JSON on every flush — replaced boolean `@traces_dirty` flag with per-trace `@dirty_trace_ids` Set so only modified traces are serialized (O(changed) instead of O(total))
data/README.md CHANGED
@@ -5,7 +5,7 @@ Domain consolidation gem for memory storage, retrieval, and consolidation. Bundl
5
5
  ## Overview
6
6
 
7
7
  **Gem**: `lex-agentic-memory`
8
- **Version**: 0.1.28
8
+ **Version**: 0.2.0
9
9
  **Namespace**: `Legion::Extensions::Agentic::Memory`
10
10
 
11
11
  ## Sub-Modules
@@ -59,6 +59,41 @@ Domain consolidation gem for memory storage, retrieval, and consolidation. Bundl
59
59
  gem 'lex-agentic-memory'
60
60
  ```
61
61
 
62
+ ## Scoped retrieval
63
+
64
+ `Trace::Client#retrieve_ranked(trace_ids:, domain_tag:, identity:)` filters
65
+ explicit candidates before ranking and returns the existing
66
+ `{ trace:, score: }` entries without reinforcing them.
67
+ `retrieve_and_reinforce` accepts optional `trace_ids:`, `domain_tag:`, and
68
+ `identity:` selectors before its limit is applied. An explicit empty ID list
69
+ selects nothing; duplicate IDs cannot reinforce twice.
70
+
71
+ Omitting `identity:` retains legacy unscoped retrieval. Explicit `identity: nil`
72
+ or a blank identity selects only traces without partner/owner tags. A supplied
73
+ identity excludes other or conflicting `partner:`/`owner:` tags. Callers must
74
+ derive identity and candidates from authorized input; retrieval is not proof
75
+ of usefulness or consent.
76
+
77
+ Default decay derives cumulative age from persisted `last_reinforced` at the
78
+ existing 60-second tick interval. Explicit `tick_count:` remains an absolute
79
+ age override. Maintenance never raises strength; reinforcement remains explicit.
80
+
81
+ ## Cache upgrade and rollback
82
+
83
+ Version 0.2.0 partitions fallback-cache keys by the agent's existing
84
+ `partition_id`. Old global cache keys cannot establish safe ownership and are
85
+ not automatically imported, read as a fallback, overwritten, or deleted.
86
+ Cache-only upgrades therefore start with an empty scoped cache and log a
87
+ warning when legacy global history is present. Preserve any required history
88
+ through controlled, ownership-verified recovery before adopting this release.
89
+ Agent-local database storage and its schema are unchanged.
90
+
91
+ Rollback to an unpartitioned cache reader is not safe: it cannot read new
92
+ scoped writes and can resurrect old global history. Use a forward fix or a
93
+ controlled cache purge/recovery with verified ownership; do not restore stale
94
+ shared cache data after selective erasure. No dual-write compatibility mode
95
+ or automatic migration is provided.
96
+
62
97
  ## Development
63
98
 
64
99
  ```bash
@@ -22,7 +22,7 @@ module Legion
22
22
  end
23
23
 
24
24
  def time
25
- 60
25
+ Legion::Extensions::Agentic::Memory::Trace::Helpers::Decay::TICK_INTERVAL
26
26
  end
27
27
 
28
28
  def run_now?
@@ -17,15 +17,12 @@ module Legion
17
17
  include Legion::Logging::Helper if defined?(Legion::Logging::Helper)
18
18
  include Legion::Cache::Helper if defined?(Legion::Cache::Helper)
19
19
 
20
- TRACE_PREFIX = 'legion:memory:trace:'
21
- INDEX_KEY = 'legion:memory:trace_index'
22
- ASSOC_KEY = 'legion:memory:associations'
23
20
  TTL = 86_400 # 24 hours
24
21
  FLUSH_BATCH = 500 # traces per flush batch
25
22
 
26
23
  attr_reader :traces, :associations
27
24
 
28
- def initialize
25
+ def initialize(partition_id: nil)
29
26
  log.info('[memory] CacheStore initialized (memcached-backed, per-key)')
30
27
  @mutex = Mutex.new
31
28
  @traces = {}
@@ -33,12 +30,16 @@ module Legion
33
30
  @dirty_ids = Set.new
34
31
  @deleted_ids = Set.new
35
32
  @assoc_dirty = false
33
+ @partition_id = partition_id || Helpers::Trace.default_partition_id
34
+ # Global legacy cache keys cannot establish agent ownership and must not be hydrated.
35
+ @cache_prefix = "legion:memory:partition:#{@partition_id}:"
36
36
  load_index
37
37
  log.info("[memory] CacheStore loaded #{@traces.size} traces from cache")
38
38
  end
39
39
 
40
40
  def store(trace)
41
41
  normalized_trace = Helpers::Trace.normalize_trace_affect(trace)
42
+ normalized_trace[:partition_id] ||= @partition_id
42
43
  @mutex.synchronize do
43
44
  @traces[normalized_trace[:trace_id]] = normalized_trace
44
45
  @dirty_ids << normalized_trace[:trace_id]
@@ -53,6 +54,9 @@ module Legion
53
54
  def delete(trace_id)
54
55
  @mutex.synchronize do
55
56
  @traces.delete(trace_id)
57
+ @traces.each_value do |trace|
58
+ @dirty_ids << trace[:trace_id] if trace[:associated_traces]&.delete(trace_id)
59
+ end
56
60
  @associations.delete(trace_id)
57
61
  @associations.each_value { |links| links.delete(trace_id) }
58
62
  @dirty_ids.delete(trace_id)
@@ -96,7 +100,10 @@ module Legion
96
100
  @associations[trace_id_b][trace_id_a] = (@associations[trace_id_b][trace_id_a] || 0) + 1
97
101
 
98
102
  threshold = Helpers::Trace::COACTIVATION_THRESHOLD
99
- link_traces(trace_id_a, trace_id_b) if @associations[trace_id_a][trace_id_b] >= threshold
103
+ if @associations[trace_id_a][trace_id_b] >= threshold && link_traces(trace_id_a, trace_id_b)
104
+ @dirty_ids << trace_id_a
105
+ @dirty_ids << trace_id_b
106
+ end
100
107
  @assoc_dirty = true
101
108
  end
102
109
  end
@@ -173,11 +180,16 @@ module Legion
173
180
  private
174
181
 
175
182
  def trace_key(trace_id)
176
- "#{TRACE_PREFIX}#{trace_id}"
183
+ "#{@cache_prefix}trace:#{trace_id}"
177
184
  end
178
185
 
179
186
  def load_index
180
- index = cache_get(INDEX_KEY)
187
+ legacy_index = cache_get('legion:memory:trace_index')
188
+ if legacy_index.is_a?(Array) && !legacy_index.empty?
189
+ log.warn('[memory] Ignoring legacy global cache history: agent ownership is ambiguous; legacy keys remain untouched')
190
+ end
191
+
192
+ index = cache_get("#{@cache_prefix}index")
181
193
  return unless index.is_a?(Array)
182
194
 
183
195
  loaded = 0
@@ -188,6 +200,14 @@ module Legion
188
200
  loaded += 1
189
201
  end
190
202
  end
203
+ associations = cache_get("#{@cache_prefix}associations")
204
+ if associations.is_a?(Hash)
205
+ @associations = associations.each_with_object({}) do |(id, links), restored|
206
+ next unless @traces.key?(id)
207
+
208
+ restored[id] = links.select { |target, _count| @traces.key?(target) }
209
+ end
210
+ end
191
211
  log.debug("[memory] CacheStore loaded #{loaded}/#{index.size} traces from index")
192
212
  rescue StandardError => e
193
213
  log.warn("[memory] CacheStore load_index failed: #{e.message}")
@@ -213,7 +233,7 @@ module Legion
213
233
  def flush_associations
214
234
  return unless @assoc_dirty
215
235
 
216
- cache_set(ASSOC_KEY, strip_default_procs(@associations), ttl: TTL)
236
+ cache_set("#{@cache_prefix}associations", strip_default_procs(@associations), ttl: TTL)
217
237
  @assoc_dirty = false
218
238
  rescue StandardError => e
219
239
  log.warn("[memory] CacheStore flush_associations failed (#{@associations.size} entries): #{e.message}")
@@ -222,7 +242,7 @@ module Legion
222
242
  def flush_index
223
243
  return if @dirty_ids.empty? && @deleted_ids.empty?
224
244
 
225
- cache_set(INDEX_KEY, @traces.keys, ttl: TTL)
245
+ cache_set("#{@cache_prefix}index", @traces.keys, ttl: TTL)
226
246
  rescue StandardError => e
227
247
  log.warn("[memory] CacheStore flush_index failed (#{@traces.size} traces): #{e.message}")
228
248
  end
@@ -239,10 +259,16 @@ module Legion
239
259
  return unless trace_a && trace_b
240
260
 
241
261
  max = Helpers::Trace::MAX_ASSOCIATIONS
242
- trace_a[:associated_traces] << id_b unless trace_a[:associated_traces].include?(id_b) || trace_a[:associated_traces].size >= max
243
- return if trace_b[:associated_traces].include?(id_a) || trace_b[:associated_traces].size >= max
244
-
245
- trace_b[:associated_traces] << id_a
262
+ changed = false
263
+ unless trace_a[:associated_traces].include?(id_b) || trace_a[:associated_traces].size >= max
264
+ trace_a[:associated_traces] << id_b
265
+ changed = true
266
+ end
267
+ unless trace_b[:associated_traces].include?(id_a) || trace_b[:associated_traces].size >= max
268
+ trace_b[:associated_traces] << id_a
269
+ changed = true
270
+ end
271
+ changed
246
272
  end
247
273
  end
248
274
  end
@@ -7,6 +7,8 @@ module Legion
7
7
  module Trace
8
8
  module Helpers
9
9
  module Decay
10
+ TICK_INTERVAL = 60
11
+
10
12
  module_function
11
13
 
12
14
  # Power-law decay formula from spec:
@@ -46,7 +46,9 @@ module Legion
46
46
  def delete(trace_id)
47
47
  @mutex.synchronize do
48
48
  removed_trace = @traces.delete(trace_id)
49
- @traces.each_value { |trace| trace[:associated_traces]&.delete(trace_id) }
49
+ @traces.each_value do |trace|
50
+ @dirty_trace_ids << trace[:trace_id] if trace[:associated_traces]&.delete(trace_id)
51
+ end
50
52
 
51
53
  removed_links = @associations.delete(trace_id)
52
54
  @associations.each_value { |links| links.delete(trace_id) }
@@ -30,7 +30,7 @@ module Legion
30
30
  { found: true, reinforced: true, trace_id: trace_id, new_strength: new_strength }
31
31
  end
32
32
 
33
- def decay_cycle(store: nil, tick_count: 1, maintenance: true, **)
33
+ def decay_cycle(store: nil, tick_count: nil, maintenance: true, **)
34
34
  unless maintenance
35
35
  deferred = deferred_decay_summary(store || cached_default_store)
36
36
  return { **deferred }
@@ -40,23 +40,29 @@ module Legion
40
40
  decayed = 0
41
41
  pruned = 0
42
42
  total = trace_count(store)
43
+ now = Time.now.utc
43
44
 
44
45
  store.all_traces.each do |trace|
45
46
  next if trace[:base_decay_rate].zero?
46
47
 
48
+ # Explicit tick_count is an age override; actor maintenance derives age from persisted access time.
49
+ ticks_since_access = tick_count || [(now - Helpers::Decay.ensure_time(trace[:last_reinforced])) /
50
+ Helpers::Decay::TICK_INTERVAL, 0].max
51
+
47
52
  new_strength = Helpers::Decay.compute_decay(
48
53
  peak_strength: trace[:peak_strength],
49
54
  base_decay_rate: trace[:base_decay_rate],
50
- ticks_since_access: tick_count,
55
+ ticks_since_access: ticks_since_access,
51
56
  emotional_intensity: trace[:emotional_intensity]
52
57
  )
58
+ new_strength = [trace[:strength], new_strength].min
53
59
 
54
60
  if new_strength <= Helpers::Trace::PRUNE_THRESHOLD
55
61
  store.delete(trace[:trace_id])
56
62
  pruned += 1
57
63
  else
58
64
  trace[:strength] = new_strength
59
- trace[:last_decayed] = Time.now.utc
65
+ trace[:last_decayed] = now
60
66
  store.store(trace)
61
67
  decayed += 1
62
68
  end
@@ -70,7 +76,7 @@ module Legion
70
76
  pruned: pruned,
71
77
  total: total,
72
78
  remaining: remaining,
73
- maintained_at: Time.now.utc
79
+ maintained_at: now
74
80
  }
75
81
  Legion::Extensions::Agentic::Memory::Trace.record_maintenance_summary(summary.dup)
76
82
 
@@ -46,12 +46,13 @@ module Legion
46
46
  { count: traces.size, traces: traces }
47
47
  end
48
48
 
49
- def retrieve_ranked(trace_ids: [], store: nil, query_time: nil, **)
49
+ def retrieve_ranked(trace_ids: [], store: nil, query_time: nil, domain_tag: nil, **scope)
50
50
  store ||= default_store
51
51
  query_time ||= Time.now.utc
52
52
  associated_set = Set.new
53
53
 
54
- traces = trace_ids.filter_map { |id| store.get(id) }
54
+ traces = trace_ids.uniq.filter_map { |id| store.get(id) }
55
+ traces = filter_trace_candidates(traces: traces, domain_tag: domain_tag, **scope)
55
56
  traces.each { |t| associated_set.merge(t[:associated_traces]) }
56
57
 
57
58
  scored = traces.map do |t|
@@ -74,10 +75,29 @@ module Legion
74
75
  { deleted: true, trace_id: trace_id }
75
76
  end
76
77
 
77
- def retrieve_and_reinforce(limit: 10, store: nil, **)
78
+ def erase_partner!(identity:, store: nil, **)
78
79
  store ||= default_store
79
- all = store.all_traces(min_strength: 0.1)
80
- top = all.sort_by { |t| -t[:strength] }.first(limit)
80
+ partner_result = store.retrieve_by_domain("partner:#{identity}", min_strength: 0.0, limit: 10_000)
81
+ owner_result = store.retrieve_by_domain("owner:#{identity}", min_strength: 0.0, limit: 10_000)
82
+ traces_to_erase = Array(partner_result) + Array(owner_result)
83
+
84
+ traces_to_erase.each { |t| store.delete(t[:trace_id]) }
85
+ persist_store(store) unless traces_to_erase.empty?
86
+
87
+ count = traces_to_erase.size
88
+ log.info("[memory] erase_partner! identity=#{identity} erased=#{count}")
89
+ { erased: true, identity: identity, count: count }
90
+ end
91
+
92
+ def retrieve_and_reinforce(limit: 10, store: nil, trace_ids: nil, domain_tag: nil, **scope)
93
+ store ||= default_store
94
+ candidates = if trace_ids
95
+ trace_ids.uniq.filter_map { |id| store.get(id) }.select { |trace| trace[:strength] >= 0.1 }
96
+ else
97
+ store.all_traces(min_strength: 0.1)
98
+ end
99
+ candidates = filter_trace_candidates(traces: candidates, domain_tag: domain_tag, **scope)
100
+ top = candidates.sort_by { |trace| -trace[:strength] }.first(limit)
81
101
 
82
102
  top.each do |trace|
83
103
  next if trace[:base_decay_rate].zero? # skip firmware
@@ -89,12 +109,30 @@ module Legion
89
109
 
90
110
  persist_store(store) unless top.empty?
91
111
 
92
- log.debug("[memory] retrieve_and_reinforce: retrieved=#{top.size} from=#{all.size} total")
112
+ log.debug("[memory] retrieve_and_reinforce: retrieved=#{top.size} from=#{candidates.size} candidates")
93
113
  { count: top.size, traces: top }
94
114
  end
95
115
 
96
116
  private
97
117
 
118
+ def filter_trace_candidates(traces:, domain_tag:, **scope)
119
+ return traces unless domain_tag || scope.key?(:identity) # rubocop:disable Legion/Extension/RunnerReturnHash -- Private helper returns candidate arrays.
120
+
121
+ traces.select do |trace|
122
+ tags = Array(trace[:domain_tags])
123
+ next false if domain_tag && !tags.include?(domain_tag)
124
+ next true unless scope.key?(:identity)
125
+
126
+ identity = scope[:identity].to_s
127
+ tags.all? do |tag|
128
+ tag = tag.to_s
129
+ next true unless tag.start_with?('partner:', 'owner:')
130
+
131
+ !identity.strip.empty? && ["partner:#{identity}", "owner:#{identity}"].include?(tag)
132
+ end
133
+ end
134
+ end
135
+
98
136
  def persist_store(store)
99
137
  store.flush if store.respond_to?(:flush)
100
138
  rescue StandardError => e
@@ -50,7 +50,7 @@ module Legion
50
50
  Helpers::PostgresStore.new(tenant_id: resolve_tenant_id, agent_id: resolve_agent_id)
51
51
  elsif defined?(Legion::Cache) && Legion::Cache.respond_to?(:connected?) && Legion::Cache.connected?
52
52
  Legion::Logging.debug '[memory] Using shared CacheStore (memcached)'
53
- Helpers::CacheStore.new
53
+ Helpers::CacheStore.new(partition_id: resolve_agent_id)
54
54
  else
55
55
  Legion::Logging.debug '[memory] Using agent-local in-memory Store'
56
56
  Helpers::Store.new(partition_id: resolve_agent_id)
@@ -4,7 +4,7 @@ module Legion
4
4
  module Extensions
5
5
  module Agentic
6
6
  module Memory
7
- VERSION = '0.1.40'
7
+ VERSION = '0.2.0'
8
8
  end
9
9
  end
10
10
  end
@@ -1,37 +1,84 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  RSpec.describe Legion::Extensions::Agentic::Memory::Trace::Helpers::CacheStore do
4
- describe '#initialize' do
5
- it 'has logging and cache helpers when loaded directly' do
6
- allow(Legion::Cache).to receive(:get).and_return(nil)
4
+ let(:cache) { {} }
5
+ let(:trace_helper) { Legion::Extensions::Agentic::Memory::Trace::Helpers::Trace }
7
6
 
8
- store = described_class.new
9
-
10
- expect(store).to respond_to(:log)
11
- expect(store).to respond_to(:cache_get)
7
+ before do
8
+ allow(Legion::Cache).to receive(:get) do |key, **|
9
+ Marshal.load(cache[key]) if cache.key?(key) # rubocop:disable Security/MarshalLoad -- Bytes are produced only by this isolated fixture.
10
+ end
11
+ allow(Legion::Cache).to receive(:set) do |key, value, **|
12
+ cache[key] = Marshal.dump(value)
13
+ true
12
14
  end
15
+ allow(Legion::Cache).to receive(:delete) { |key, **| cache.delete(key) }
16
+ end
17
+
18
+ it 'keeps trace contents, indexes, and deletion isolated across agent restarts' do
19
+ alice = described_class.new(partition_id: 'alice')
20
+ bob = described_class.new(partition_id: 'bob')
21
+ trace = trace_helper.new_trace(type: :semantic, content_payload: { fact: 'alice' }, partition_id: 'alice')
22
+ alice.store(trace)
23
+ bob.store(trace.merge(content_payload: { fact: 'bob' }, partition_id: 'bob'))
24
+ alice.flush
25
+ bob.flush
26
+
27
+ alice = described_class.new(partition_id: 'alice')
28
+ bob = described_class.new(partition_id: 'bob')
29
+ expect(alice.get(trace[:trace_id])[:content_payload]).to eq(fact: 'alice')
30
+ expect(bob.get(trace[:trace_id])[:content_payload]).to eq(fact: 'bob')
31
+
32
+ alice.delete(trace[:trace_id])
33
+ alice.flush
34
+ bob.reload
35
+ expect(described_class.new(partition_id: 'alice').all_traces).to eq([])
36
+ expect(bob.get(trace[:trace_id])[:content_payload]).to eq(fact: 'bob')
37
+ end
38
+
39
+ it 'does not hydrate ambiguous legacy global cache records' do
40
+ private_trace = trace_helper.new_trace(type: :semantic, content_payload: { fact: 'private' }, partition_id: 'bob')
41
+ legacy_writer = described_class.new(partition_id: 'legacy-fixture')
42
+ legacy_writer.cache_set('legion:memory:trace_index', [private_trace[:trace_id]])
43
+ legacy_writer.cache_set("legion:memory:trace:#{private_trace[:trace_id]}", private_trace)
44
+ legacy_records = cache.dup
45
+
46
+ expect(described_class.new(partition_id: 'alice').all_traces).to eq([])
47
+ expect(described_class.new(partition_id: 'bob').all_traces).to eq([])
48
+
49
+ store = described_class.new(partition_id: 'alice')
50
+ store.store(private_trace.merge(content_payload: { fact: 'new scoped trace' }, partition_id: 'alice'))
51
+ store.flush
52
+ store.delete(private_trace[:trace_id])
53
+ store.flush
54
+
55
+ expect(cache.slice(*legacy_records.keys)).to eq(legacy_records)
13
56
  end
14
57
 
15
- describe '#flush' do
16
- it 'passes cache TTL as a keyword argument' do
17
- allow(Legion::Cache).to receive(:get).and_return(nil)
18
- allow(Legion::Cache).to receive(:set).and_return(true)
19
- store = described_class.new
20
- trace = Legion::Extensions::Agentic::Memory::Trace::Helpers::Trace.new_trace(
21
- type: :semantic,
22
- content_payload: { fact: 'cache flush' }
23
- )
24
-
25
- store.store(trace)
26
- store.flush
27
-
28
- expect(Legion::Cache).to have_received(:set).with(
29
- a_string_including(trace[:trace_id]),
30
- trace,
31
- ttl: described_class::TTL,
32
- async: false,
33
- phi: false
34
- )
58
+ it 'preserves scoped coactivation progress and persisted links without resurrecting deleted links' do
59
+ store = described_class.new(partition_id: 'alice')
60
+ first = trace_helper.new_trace(type: :semantic, content_payload: { fact: 'first' }, partition_id: 'alice')
61
+ second = trace_helper.new_trace(type: :semantic, content_payload: { fact: 'second' }, partition_id: 'alice')
62
+ store.store(first)
63
+ store.store(second)
64
+ store.record_coactivation(first[:trace_id], second[:trace_id])
65
+ store.flush
66
+
67
+ restarted = described_class.new(partition_id: 'alice')
68
+ (trace_helper::COACTIVATION_THRESHOLD - 1).times do
69
+ restarted.record_coactivation(first[:trace_id], second[:trace_id])
35
70
  end
71
+ restarted.flush
72
+ restarted.reload
73
+
74
+ expect(restarted.retrieve_associated(first[:trace_id]).map { |trace| trace[:trace_id] }).to eq([second[:trace_id]])
75
+ expect(described_class.new(partition_id: 'bob').associations).to eq({})
76
+
77
+ restarted.delete(second[:trace_id])
78
+ restarted.flush
79
+ restarted.reload
80
+ expect(restarted.get(second[:trace_id])).to be_nil
81
+ expect(restarted.get(first[:trace_id])[:associated_traces]).to eq([])
82
+ expect(restarted.associations.fetch(first[:trace_id], {})).to eq({})
36
83
  end
37
84
  end
@@ -123,7 +123,7 @@ RSpec.describe Legion::Extensions::Agentic::Memory::Trace::Helpers::ErrorTracer
123
123
  described_class.instance_variable_set(:@write_queue, worker_queue)
124
124
  allow(runner_double).to receive(:store_trace).and_raise(StandardError, 'db down')
125
125
 
126
- # rubocop:disable ThreadSafety/NewThread
126
+ # rubocop:disable-next ThreadSafety/NewThread
127
127
  worker = Thread.new do
128
128
  payload = worker_queue.pop
129
129
  break if payload == :stop
@@ -132,7 +132,6 @@ RSpec.describe Legion::Extensions::Agentic::Memory::Trace::Helpers::ErrorTracer
132
132
  rescue StandardError
133
133
  nil
134
134
  end
135
- # rubocop:enable ThreadSafety/NewThread
136
135
 
137
136
  described_class.send(:record_trace, 'store failure', :error)
138
137
  expect { worker.join(2) }.not_to raise_error
@@ -142,7 +141,7 @@ RSpec.describe Legion::Extensions::Agentic::Memory::Trace::Helpers::ErrorTracer
142
141
  dedicated_queue = Queue.new
143
142
  described_class.instance_variable_set(:@write_queue, dedicated_queue)
144
143
 
145
- # rubocop:disable ThreadSafety/NewThread
144
+ # rubocop:disable-next ThreadSafety/NewThread
146
145
  worker = Thread.new do
147
146
  loop do
148
147
  payload = dedicated_queue.pop
@@ -155,7 +154,6 @@ RSpec.describe Legion::Extensions::Agentic::Memory::Trace::Helpers::ErrorTracer
155
154
  nil
156
155
  end
157
156
  end
158
- # rubocop:enable ThreadSafety/NewThread
159
157
 
160
158
  described_class.send(:record_trace, '[mycomponent] disk full', :error)
161
159
  dedicated_queue.push(:stop)
@@ -261,7 +261,7 @@ RSpec.describe Legion::Extensions::Agentic::Memory::Trace::Helpers::Store do
261
261
  end
262
262
  end
263
263
 
264
- describe 'parse_db_json nil/empty guard (log spam regression)' do
264
+ describe 'local persistence' do
265
265
  let(:local_db) do
266
266
  d = Sequel.sqlite
267
267
  d.create_table(:memory_traces) do
@@ -331,6 +331,23 @@ RSpec.describe Legion::Extensions::Agentic::Memory::Trace::Helpers::Store do
331
331
  expect(trace[:associated_traces]).to eq([])
332
332
  expect(trace[:child_trace_ids]).to eq([])
333
333
  end
334
+
335
+ it 'does not resurrect deleted association references after a restart' do
336
+ store.store(semantic_trace)
337
+ store.store(episodic_trace)
338
+ trace_helper::COACTIVATION_THRESHOLD.times do
339
+ store.record_coactivation(semantic_trace[:trace_id], episodic_trace[:trace_id])
340
+ end
341
+ store.flush
342
+
343
+ store.delete(episodic_trace[:trace_id])
344
+ store.flush
345
+ restarted = described_class.new
346
+
347
+ expect(restarted.get(episodic_trace[:trace_id])).to be_nil
348
+ expect(restarted.get(semantic_trace[:trace_id])[:associated_traces]).to eq([])
349
+ expect(restarted.retrieve_associated(semantic_trace[:trace_id])).to eq([])
350
+ end
334
351
  end
335
352
 
336
353
  describe '#restore_traces' do
@@ -102,6 +102,48 @@ RSpec.describe Legion::Extensions::Agentic::Memory::Trace::Runners::Consolidatio
102
102
  expect(result[:decayed]).to be >= 1
103
103
  end
104
104
 
105
+ it 'continues the same power-law aging across repeated maintenance and snapshot restore' do
106
+ started_at = Time.utc(2026, 10, 29)
107
+ allow(Time).to receive(:now).and_return(started_at)
108
+ store = Legion::Extensions::Agentic::Memory::Trace::Helpers::Store.new
109
+ stored = client.store_trace(type: :semantic, content_payload: {}, store: store)
110
+ id = stored[:trace_id]
111
+
112
+ allow(Time).to receive(:now).and_return(started_at + 60)
113
+ client.decay_cycle(store: store)
114
+ first_strength = store.get(id)[:strength]
115
+ expect(first_strength).to be_within(1e-12).of(0.5 * (2.0**-0.01))
116
+
117
+ restored = Legion::Extensions::Agentic::Memory::Trace::Helpers::Store.new
118
+ restored.restore_traces(Marshal.load(Marshal.dump(store.all_traces)))
119
+ allow(Time).to receive(:now).and_return(started_at + 120)
120
+ client.decay_cycle(store: restored)
121
+
122
+ expect(restored.get(id)[:strength]).to be_within(1e-12).of(0.5 * (3.0**-0.01))
123
+ expect(restored.get(id)[:strength]).to be < first_strength
124
+ second_strength = restored.get(id)[:strength]
125
+ client.decay_cycle(store: restored)
126
+ expect(restored.get(id)[:strength]).to eq(second_strength)
127
+ end
128
+
129
+ it 'restarts aging from explicit reinforcement without restoring historical peak strength during maintenance' do
130
+ started_at = Time.utc(2026, 10, 29)
131
+ allow(Time).to receive(:now).and_return(started_at)
132
+ id = client.store_trace(type: :semantic, content_payload: {})[:trace_id]
133
+
134
+ client.decay_cycle(tick_count: 100)
135
+ aged_strength = client.get_trace(trace_id: id)[:trace][:strength]
136
+ client.decay_cycle
137
+ expect(client.get_trace(trace_id: id)[:trace][:strength]).to eq(aged_strength)
138
+
139
+ allow(Time).to receive(:now).and_return(started_at + 120)
140
+ reinforced = client.reinforce(trace_id: id)[:new_strength]
141
+ allow(Time).to receive(:now).and_return(started_at + 180)
142
+ client.decay_cycle
143
+
144
+ expect(client.get_trace(trace_id: id)[:trace][:strength]).to be_within(1e-12).of(reinforced * (2.0**-0.01))
145
+ end
146
+
105
147
  it 'prunes traces below threshold' do
106
148
  stored = client.store_trace(type: :sensory, content_payload: {})
107
149
  trace = client.get_trace(trace_id: stored[:trace_id])
@@ -109,7 +151,8 @@ RSpec.describe Legion::Extensions::Agentic::Memory::Trace::Runners::Consolidatio
109
151
  trace[:trace][:peak_strength] = 0.005
110
152
 
111
153
  result = client.decay_cycle(tick_count: 100_000)
112
- expect(result[:pruned]).to be >= 0
154
+ expect(result[:pruned]).to eq(1)
155
+ expect(client.get_trace(trace_id: stored[:trace_id])[:found]).to be false
113
156
  end
114
157
  end
115
158
 
@@ -58,6 +58,74 @@ RSpec.describe Legion::Extensions::Agentic::Memory::Trace::Runners::Traces do
58
58
  end
59
59
  end
60
60
 
61
+ describe 'scoped retrieval' do
62
+ let(:alice) do
63
+ client.store_trace(type: :semantic, content_payload: { fact: 'alice work' }, domain_tags: ['work', 'partner:alice'])
64
+ end
65
+ let(:bob) do
66
+ client.store_trace(type: :identity, content_payload: { fact: 'bob work' }, domain_tags: ['work', 'owner:bob'])
67
+ end
68
+ let(:unrelated) do
69
+ client.store_trace(type: :identity, content_payload: { fact: 'alice home' }, domain_tags: ['home', 'partner:alice'])
70
+ end
71
+ let(:ids) { [alice, bob, unrelated].map { |trace| trace[:trace_id] } }
72
+
73
+ it 'filters ranked candidates by principal and domain without reinforcing them' do
74
+ before = client.get_trace(trace_id: alice[:trace_id])[:trace].dup
75
+
76
+ result = client.retrieve_ranked(trace_ids: ids, domain_tag: 'work', identity: 'alice')
77
+
78
+ expect(result[:traces].map { |entry| entry[:trace][:trace_id] }).to eq([alice[:trace_id]])
79
+ expect(result[:traces].first[:score]).to be > 0
80
+ expect(client.get_trace(trace_id: alice[:trace_id])[:trace]).to eq(before)
81
+ end
82
+
83
+ it 'does not return private or conflicting-owner traces to an anonymous or different principal' do
84
+ shared = client.store_trace(type: :semantic, content_payload: { fact: 'public' }, domain_tags: ['work'])
85
+ mixed = client.store_trace(type: :semantic, content_payload: {},
86
+ domain_tags: ['work', 'partner:alice', 'owner:bob'])
87
+ candidates = ids + [shared[:trace_id], mixed[:trace_id]]
88
+
89
+ anonymous = client.retrieve_ranked(trace_ids: candidates, identity: nil)
90
+ scoped = client.retrieve_ranked(trace_ids: candidates, domain_tag: 'work', identity: 'alice')
91
+
92
+ expect(anonymous[:traces].map { |entry| entry[:trace][:trace_id] }).to eq([shared[:trace_id]])
93
+ expect(scoped[:traces].map { |entry| entry[:trace][:trace_id] }).to contain_exactly(alice[:trace_id], shared[:trace_id])
94
+ end
95
+
96
+ it 'keeps unscoped explicit candidate ranking available to standalone callers' do
97
+ result = client.retrieve_ranked(trace_ids: ids)
98
+
99
+ expect(result[:traces].map { |entry| entry[:trace][:trace_id] }).to contain_exactly(*ids)
100
+ expect(client.retrieve_ranked[:traces]).to eq([])
101
+ end
102
+
103
+ it 'reinforces only authorized relevant candidates before applying the limit' do
104
+ result = client.retrieve_and_reinforce(trace_ids: ids + [alice[:trace_id]], domain_tag: 'work', identity: 'alice', limit: 1)
105
+
106
+ expect(result[:traces].map { |trace| trace[:trace_id] }).to eq([alice[:trace_id]])
107
+ expect(client.get_trace(trace_id: alice[:trace_id])[:trace][:reinforcement_count]).to eq(1)
108
+ expect(client.get_trace(trace_id: bob[:trace_id])[:trace][:reinforcement_count]).to eq(0)
109
+ expect(client.get_trace(trace_id: unrelated[:trace_id])[:trace][:reinforcement_count]).to eq(0)
110
+ end
111
+
112
+ it 'does not fall back to global traces when an explicit candidate list is empty' do
113
+ alice
114
+
115
+ expect(client.retrieve_and_reinforce(trace_ids: [])).to eq(count: 0, traces: [])
116
+ expect(client.get_trace(trace_id: alice[:trace_id])[:trace][:reinforcement_count]).to eq(0)
117
+ end
118
+
119
+ it 'selects domain candidates from the store when no ids are supplied' do
120
+ ids
121
+
122
+ result = client.retrieve_and_reinforce(domain_tag: 'work', identity: 'alice', limit: 1)
123
+
124
+ expect(result[:traces].map { |trace| trace[:trace_id] }).to eq([alice[:trace_id]])
125
+ expect(client.get_trace(trace_id: bob[:trace_id])[:trace][:reinforcement_count]).to eq(0)
126
+ end
127
+ end
128
+
61
129
  describe '#delete_trace' do
62
130
  it 'removes a trace' do
63
131
  stored = client.store_trace(type: :semantic, content_payload: {})
@@ -66,4 +134,50 @@ RSpec.describe Legion::Extensions::Agentic::Memory::Trace::Runners::Traces do
66
134
  expect(result[:found]).to be false
67
135
  end
68
136
  end
137
+
138
+ describe '#erase_partner!' do
139
+ it 'erases all partner-tagged traces for the given identity' do
140
+ 3.times { client.store_trace(type: :semantic, content_payload: {}, domain_tags: ['partner:alice']) }
141
+ 2.times { client.store_trace(type: :semantic, content_payload: {}, domain_tags: ['partner:bob']) }
142
+
143
+ result = client.erase_partner!(identity: 'alice')
144
+
145
+ expect(result[:erased]).to be true
146
+ expect(result[:identity]).to eq('alice')
147
+ expect(result[:count]).to eq(3)
148
+
149
+ expect(client.retrieve_by_domain(domain_tag: 'partner:alice')[:count]).to eq(0)
150
+ expect(client.retrieve_by_domain(domain_tag: 'partner:bob')[:count]).to eq(2)
151
+ end
152
+
153
+ it 'erases owner-tagged traces for the given identity' do
154
+ 2.times { client.store_trace(type: :semantic, content_payload: {}, domain_tags: ['owner:alice']) }
155
+ client.store_trace(type: :semantic, content_payload: {}, domain_tags: ['partner:alice'])
156
+
157
+ result = client.erase_partner!(identity: 'alice')
158
+
159
+ expect(result[:count]).to eq(3)
160
+ expect(client.retrieve_by_domain(domain_tag: 'owner:alice')[:count]).to eq(0)
161
+ expect(client.retrieve_by_domain(domain_tag: 'partner:alice')[:count]).to eq(0)
162
+ end
163
+
164
+ it 'returns count: 0 when no traces exist for the identity' do
165
+ result = client.erase_partner!(identity: 'ghost')
166
+
167
+ expect(result[:erased]).to be true
168
+ expect(result[:identity]).to eq('ghost')
169
+ expect(result[:count]).to eq(0)
170
+ end
171
+
172
+ it 'does not erase traces belonging to other identities' do
173
+ client.store_trace(type: :semantic, content_payload: {}, domain_tags: ['partner:alice'])
174
+ client.store_trace(type: :semantic, content_payload: {}, domain_tags: ['partner:bob'])
175
+ client.store_trace(type: :semantic, content_payload: {}, domain_tags: ['owner:carol'])
176
+
177
+ client.erase_partner!(identity: 'alice')
178
+
179
+ expect(client.retrieve_by_domain(domain_tag: 'partner:bob')[:count]).to eq(1)
180
+ expect(client.retrieve_by_domain(domain_tag: 'owner:carol')[:count]).to eq(1)
181
+ end
182
+ end
69
183
  end
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.40
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity