lex-agentic-memory 0.1.41 → 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 +4 -4
- data/CHANGELOG.md +10 -0
- data/README.md +36 -1
- data/lib/legion/extensions/agentic/memory/trace/actors/decay.rb +1 -1
- data/lib/legion/extensions/agentic/memory/trace/helpers/cache_store.rb +39 -13
- data/lib/legion/extensions/agentic/memory/trace/helpers/decay.rb +2 -0
- data/lib/legion/extensions/agentic/memory/trace/helpers/store.rb +3 -1
- data/lib/legion/extensions/agentic/memory/trace/runners/consolidation.rb +10 -4
- data/lib/legion/extensions/agentic/memory/trace/runners/traces.rb +30 -6
- data/lib/legion/extensions/agentic/memory/trace.rb +1 -1
- data/lib/legion/extensions/agentic/memory/version.rb +1 -1
- data/spec/legion/extensions/agentic/memory/trace/helpers/cache_store_spec.rb +74 -27
- data/spec/legion/extensions/agentic/memory/trace/helpers/error_tracer_spec.rb +2 -4
- data/spec/legion/extensions/agentic/memory/trace/helpers/store_spec.rb +18 -1
- data/spec/legion/extensions/agentic/memory/trace/runners/consolidation_spec.rb +44 -1
- data/spec/legion/extensions/agentic/memory/trace/runners/traces_spec.rb +68 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a82760274cd082ae6c5b20be2506a95b6a83428ec6e2a7c03340108d8b01a3b5
|
|
4
|
+
data.tar.gz: 1c95b9324954ea83899f27bc49d989405d22c21dd3a83f26248c87c8e01aba0a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5ed2b7b49558c903d03400562dd3bef0cd90aec5e65f19e520f2b82810f74498fcda622445ec632778f36b9275f019a462faa4ee476dd6e6b32e2848d0fd453c
|
|
7
|
+
data.tar.gz: 9923a37c18191b9b6e6bcccbd9765e800d50fb74f13d053c3262599141cbe88060bb494b69f30e6c592d604da1694902fefc75c1c9a584e6786bdee7ccf23a18
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
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
|
+
|
|
3
13
|
## [0.1.41] - 2026-07-22
|
|
4
14
|
### Added
|
|
5
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).
|
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.
|
|
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
|
|
@@ -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
|
-
|
|
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
|
-
"#{
|
|
183
|
+
"#{@cache_prefix}trace:#{trace_id}"
|
|
177
184
|
end
|
|
178
185
|
|
|
179
186
|
def load_index
|
|
180
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
|
@@ -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
|
|
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:
|
|
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:
|
|
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] =
|
|
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:
|
|
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|
|
|
@@ -88,10 +89,15 @@ module Legion
|
|
|
88
89
|
{ erased: true, identity: identity, count: count }
|
|
89
90
|
end
|
|
90
91
|
|
|
91
|
-
def retrieve_and_reinforce(limit: 10, store: nil, **)
|
|
92
|
+
def retrieve_and_reinforce(limit: 10, store: nil, trace_ids: nil, domain_tag: nil, **scope)
|
|
92
93
|
store ||= default_store
|
|
93
|
-
|
|
94
|
-
|
|
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)
|
|
95
101
|
|
|
96
102
|
top.each do |trace|
|
|
97
103
|
next if trace[:base_decay_rate].zero? # skip firmware
|
|
@@ -103,12 +109,30 @@ module Legion
|
|
|
103
109
|
|
|
104
110
|
persist_store(store) unless top.empty?
|
|
105
111
|
|
|
106
|
-
log.debug("[memory] retrieve_and_reinforce: retrieved=#{top.size} from=#{
|
|
112
|
+
log.debug("[memory] retrieve_and_reinforce: retrieved=#{top.size} from=#{candidates.size} candidates")
|
|
107
113
|
{ count: top.size, traces: top }
|
|
108
114
|
end
|
|
109
115
|
|
|
110
116
|
private
|
|
111
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
|
+
|
|
112
136
|
def persist_store(store)
|
|
113
137
|
store.flush if store.respond_to?(:flush)
|
|
114
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)
|
|
@@ -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
|
-
|
|
5
|
-
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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 '
|
|
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
|
|
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: {})
|