lex-memory 0.1.2
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 +7 -0
- data/.github/workflows/ci.yml +16 -0
- data/.gitignore +12 -0
- data/.rspec +3 -0
- data/.rubocop.yml +53 -0
- data/CHANGELOG.md +22 -0
- data/CLAUDE.md +129 -0
- data/Gemfile +15 -0
- data/Gemfile.lock +99 -0
- data/README.md +158 -0
- data/lex-memory.gemspec +28 -0
- data/lib/legion/extensions/memory/actors/decay.rb +41 -0
- data/lib/legion/extensions/memory/actors/tier_migration.rb +41 -0
- data/lib/legion/extensions/memory/client.rb +28 -0
- data/lib/legion/extensions/memory/helpers/cache_store.rb +163 -0
- data/lib/legion/extensions/memory/helpers/decay.rb +64 -0
- data/lib/legion/extensions/memory/helpers/error_tracer.rb +90 -0
- data/lib/legion/extensions/memory/helpers/store.rb +256 -0
- data/lib/legion/extensions/memory/helpers/trace.rb +102 -0
- data/lib/legion/extensions/memory/local_migrations/20260316000001_create_memory_traces.rb +31 -0
- data/lib/legion/extensions/memory/local_migrations/20260316000002_create_memory_associations.rb +13 -0
- data/lib/legion/extensions/memory/runners/consolidation.rb +117 -0
- data/lib/legion/extensions/memory/runners/traces.rb +101 -0
- data/lib/legion/extensions/memory/version.rb +9 -0
- data/lib/legion/extensions/memory.rb +52 -0
- metadata +71 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Memory
|
|
6
|
+
module Helpers
|
|
7
|
+
# Cache-backed store using Legion::Cache (Memcached/Redis).
|
|
8
|
+
# Keeps a local copy in memory, syncs to/from cache on load/flush.
|
|
9
|
+
# Call `flush` after a batch of writes, or it auto-flushes when dirty.
|
|
10
|
+
# Call `reload` to pull latest state from cache (e.g. after another process wrote).
|
|
11
|
+
class CacheStore
|
|
12
|
+
TRACES_KEY = 'legion:memory:traces'
|
|
13
|
+
ASSOC_KEY = 'legion:memory:associations'
|
|
14
|
+
TTL = 86_400 # 24 hours
|
|
15
|
+
|
|
16
|
+
attr_reader :traces, :associations
|
|
17
|
+
|
|
18
|
+
def initialize
|
|
19
|
+
Legion::Logging.info '[memory] CacheStore initialized (memcached-backed)'
|
|
20
|
+
@traces = Legion::Cache.get(TRACES_KEY) || {}
|
|
21
|
+
@associations = Legion::Cache.get(ASSOC_KEY) || {}
|
|
22
|
+
@dirty = false
|
|
23
|
+
Legion::Logging.info "[memory] CacheStore loaded #{@traces.size} traces from cache"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def store(trace)
|
|
27
|
+
@traces[trace[:trace_id]] = trace
|
|
28
|
+
@dirty = true
|
|
29
|
+
trace[:trace_id]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def get(trace_id)
|
|
33
|
+
@traces[trace_id]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def delete(trace_id)
|
|
37
|
+
@traces.delete(trace_id)
|
|
38
|
+
@associations.delete(trace_id)
|
|
39
|
+
@associations.each_value { |links| links.delete(trace_id) }
|
|
40
|
+
@dirty = true
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def retrieve_by_type(type, min_strength: 0.0, limit: 100)
|
|
44
|
+
@traces.values
|
|
45
|
+
.select { |t| t[:trace_type] == type && t[:strength] >= min_strength }
|
|
46
|
+
.sort_by { |t| -t[:strength] }
|
|
47
|
+
.first(limit)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def retrieve_by_domain(domain_tag, min_strength: 0.0, limit: 100)
|
|
51
|
+
@traces.values
|
|
52
|
+
.select { |t| t[:domain_tags].include?(domain_tag) && t[:strength] >= min_strength }
|
|
53
|
+
.sort_by { |t| -t[:strength] }
|
|
54
|
+
.first(limit)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def retrieve_associated(trace_id, min_strength: 0.0, limit: 20)
|
|
58
|
+
trace = @traces[trace_id]
|
|
59
|
+
return [] unless trace
|
|
60
|
+
|
|
61
|
+
trace[:associated_traces]
|
|
62
|
+
.filter_map { |id| @traces[id] }
|
|
63
|
+
.select { |t| t[:strength] >= min_strength }
|
|
64
|
+
.sort_by { |t| -t[:strength] }
|
|
65
|
+
.first(limit)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def record_coactivation(trace_id_a, trace_id_b)
|
|
69
|
+
return if trace_id_a == trace_id_b
|
|
70
|
+
|
|
71
|
+
@associations[trace_id_a] ||= {}
|
|
72
|
+
@associations[trace_id_b] ||= {}
|
|
73
|
+
@associations[trace_id_a][trace_id_b] = (@associations[trace_id_a][trace_id_b] || 0) + 1
|
|
74
|
+
@associations[trace_id_b][trace_id_a] = (@associations[trace_id_b][trace_id_a] || 0) + 1
|
|
75
|
+
|
|
76
|
+
threshold = Helpers::Trace::COACTIVATION_THRESHOLD
|
|
77
|
+
link_traces(trace_id_a, trace_id_b) if @associations[trace_id_a][trace_id_b] >= threshold
|
|
78
|
+
@dirty = true
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def all_traces(min_strength: 0.0)
|
|
82
|
+
@traces.values.select { |t| t[:strength] >= min_strength }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def count
|
|
86
|
+
@traces.size
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def firmware_traces
|
|
90
|
+
retrieve_by_type(:firmware)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def walk_associations(start_id:, max_hops: 12, min_strength: 0.1)
|
|
94
|
+
return [] unless @traces.key?(start_id)
|
|
95
|
+
|
|
96
|
+
results = []
|
|
97
|
+
visited = Set.new([start_id])
|
|
98
|
+
queue = [[start_id, 0, [start_id]]]
|
|
99
|
+
|
|
100
|
+
until queue.empty?
|
|
101
|
+
current_id, depth, path = queue.shift
|
|
102
|
+
current = @traces[current_id]
|
|
103
|
+
next unless current
|
|
104
|
+
|
|
105
|
+
current[:associated_traces].each do |neighbor_id|
|
|
106
|
+
next if visited.include?(neighbor_id)
|
|
107
|
+
|
|
108
|
+
neighbor = @traces[neighbor_id]
|
|
109
|
+
next unless neighbor
|
|
110
|
+
next unless neighbor[:strength] >= min_strength
|
|
111
|
+
|
|
112
|
+
visited << neighbor_id
|
|
113
|
+
neighbor_path = path + [neighbor_id]
|
|
114
|
+
results << { trace_id: neighbor_id, depth: depth + 1, path: neighbor_path }
|
|
115
|
+
queue << [neighbor_id, depth + 1, neighbor_path] if depth + 1 < max_hops
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
results
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Write local state to cache
|
|
123
|
+
def flush
|
|
124
|
+
return unless @dirty
|
|
125
|
+
|
|
126
|
+
Legion::Cache.set(TRACES_KEY, @traces, TTL)
|
|
127
|
+
Legion::Cache.set(ASSOC_KEY, strip_default_procs(@associations), TTL)
|
|
128
|
+
@dirty = false
|
|
129
|
+
Legion::Logging.debug "[memory] CacheStore flushed #{@traces.size} traces to cache"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# Pull latest state from cache (after another process wrote)
|
|
133
|
+
def reload
|
|
134
|
+
@traces = Legion::Cache.get(TRACES_KEY) || {}
|
|
135
|
+
@associations = Legion::Cache.get(ASSOC_KEY) || {}
|
|
136
|
+
@dirty = false
|
|
137
|
+
Legion::Logging.debug "[memory] CacheStore reloaded #{@traces.size} traces from cache"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
private
|
|
141
|
+
|
|
142
|
+
def strip_default_procs(hash)
|
|
143
|
+
hash.each_with_object({}) do |(k, v), plain|
|
|
144
|
+
plain[k] = v.is_a?(Hash) ? {}.merge(v) : v
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def link_traces(id_a, id_b)
|
|
149
|
+
trace_a = @traces[id_a]
|
|
150
|
+
trace_b = @traces[id_b]
|
|
151
|
+
return unless trace_a && trace_b
|
|
152
|
+
|
|
153
|
+
max = Helpers::Trace::MAX_ASSOCIATIONS
|
|
154
|
+
trace_a[:associated_traces] << id_b unless trace_a[:associated_traces].include?(id_b) || trace_a[:associated_traces].size >= max
|
|
155
|
+
return if trace_b[:associated_traces].include?(id_a) || trace_b[:associated_traces].size >= max
|
|
156
|
+
|
|
157
|
+
trace_b[:associated_traces] << id_a
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Memory
|
|
6
|
+
module Helpers
|
|
7
|
+
module Decay
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# Power-law decay formula from spec:
|
|
11
|
+
# new_strength = peak_strength * (ticks_since_access + 1)^(-base_decay_rate / (1 + emotional_intensity * E_WEIGHT))
|
|
12
|
+
def compute_decay(peak_strength:, base_decay_rate:, ticks_since_access:, emotional_intensity: 0.0, **)
|
|
13
|
+
return peak_strength if base_decay_rate.zero?
|
|
14
|
+
return 0.0 if peak_strength.zero?
|
|
15
|
+
|
|
16
|
+
e_weight = Helpers::Trace::E_WEIGHT
|
|
17
|
+
effective_rate = base_decay_rate / (1.0 + (emotional_intensity * e_weight))
|
|
18
|
+
new_strength = peak_strength * ((ticks_since_access + 1).to_f**(-effective_rate))
|
|
19
|
+
new_strength.clamp(0.0, 1.0)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Reinforcement formula from spec:
|
|
23
|
+
# new_strength = min(1.0, current_strength + R_AMOUNT * IMPRINT_MULTIPLIER_if_applicable)
|
|
24
|
+
def compute_reinforcement(current_strength:, imprint_active: false, **)
|
|
25
|
+
r_amount = Helpers::Trace::R_AMOUNT
|
|
26
|
+
multiplier = imprint_active ? Helpers::Trace::IMPRINT_MULTIPLIER : 1.0
|
|
27
|
+
new_strength = current_strength + (r_amount * multiplier)
|
|
28
|
+
new_strength.clamp(0.0, 1.0)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Composite retrieval score from spec:
|
|
32
|
+
# score = strength * recency_factor * emotional_weight * (1 + association_bonus)
|
|
33
|
+
def compute_retrieval_score(trace:, query_time: nil, associated: false, **)
|
|
34
|
+
query_time ||= Time.now.utc
|
|
35
|
+
seconds_since = [query_time - trace[:last_reinforced], 0].max
|
|
36
|
+
half_life = Helpers::Trace::RETRIEVAL_RECENCY_HALF.to_f
|
|
37
|
+
|
|
38
|
+
recency_factor = 2.0**(-seconds_since / half_life)
|
|
39
|
+
emotional_weight = 1.0 + trace[:emotional_intensity]
|
|
40
|
+
assoc_bonus = associated ? (1.0 + Helpers::Trace::ASSOCIATION_BONUS) : 1.0
|
|
41
|
+
|
|
42
|
+
trace[:strength] * recency_factor * emotional_weight * assoc_bonus
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Determine storage tier based on last access time
|
|
46
|
+
def compute_storage_tier(trace:, now: nil, **)
|
|
47
|
+
now ||= Time.now.utc
|
|
48
|
+
seconds_since = now - trace[:last_reinforced]
|
|
49
|
+
|
|
50
|
+
if trace[:strength] <= Helpers::Trace::PRUNE_THRESHOLD
|
|
51
|
+
:erased
|
|
52
|
+
elsif seconds_since <= Helpers::Trace::HOT_TIER_WINDOW
|
|
53
|
+
:hot
|
|
54
|
+
elsif seconds_since <= Helpers::Trace::WARM_TIER_WINDOW
|
|
55
|
+
:warm
|
|
56
|
+
else
|
|
57
|
+
:cold
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Memory
|
|
6
|
+
module Helpers
|
|
7
|
+
module ErrorTracer
|
|
8
|
+
DEBOUNCE_WINDOW = 60 # seconds
|
|
9
|
+
ERROR_VALENCE = -0.6
|
|
10
|
+
ERROR_INTENSITY = 0.7
|
|
11
|
+
FATAL_VALENCE = -0.8
|
|
12
|
+
FATAL_INTENSITY = 0.9
|
|
13
|
+
|
|
14
|
+
class << self
|
|
15
|
+
def setup
|
|
16
|
+
return if @active
|
|
17
|
+
|
|
18
|
+
@recent = {}
|
|
19
|
+
@runner = Object.new.extend(Legion::Extensions::Memory::Runners::Traces)
|
|
20
|
+
wrap_logging_methods
|
|
21
|
+
@active = true
|
|
22
|
+
Legion::Logging.info '[memory] ErrorTracer active — errors/fatals will become episodic traces'
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def active?
|
|
26
|
+
@active == true
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def wrap_logging_methods
|
|
32
|
+
original_error = Legion::Logging.method(:error)
|
|
33
|
+
original_fatal = Legion::Logging.method(:fatal)
|
|
34
|
+
|
|
35
|
+
Legion::Logging.define_singleton_method(:error) do |message = nil, &block|
|
|
36
|
+
message = block.call if message.nil? && block
|
|
37
|
+
original_error.call(message)
|
|
38
|
+
ErrorTracer.send(:record_trace, message, :error) if message.is_a?(String)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
Legion::Logging.define_singleton_method(:fatal) do |message = nil, &block|
|
|
42
|
+
message = block.call if message.nil? && block
|
|
43
|
+
original_fatal.call(message)
|
|
44
|
+
ErrorTracer.send(:record_trace, message, :fatal) if message.is_a?(String)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def record_trace(message, level)
|
|
49
|
+
return unless message.is_a?(String) && !message.empty?
|
|
50
|
+
|
|
51
|
+
# Debounce: skip if same message within window
|
|
52
|
+
now = Time.now.utc
|
|
53
|
+
key = "#{level}:#{message[0..100]}"
|
|
54
|
+
return if @recent[key] && (now - @recent[key]) < DEBOUNCE_WINDOW
|
|
55
|
+
|
|
56
|
+
@recent[key] = now
|
|
57
|
+
|
|
58
|
+
# Clean old entries periodically
|
|
59
|
+
@recent.delete_if { |_, t| (now - t) > DEBOUNCE_WINDOW } if @recent.size > 500
|
|
60
|
+
|
|
61
|
+
# Extract component from [bracket] prefix
|
|
62
|
+
component = message.match(/\A\[([^\]]+)\]/)&.captures&.first || 'unknown'
|
|
63
|
+
|
|
64
|
+
valence = level == :fatal ? FATAL_VALENCE : ERROR_VALENCE
|
|
65
|
+
intensity = level == :fatal ? FATAL_INTENSITY : ERROR_INTENSITY
|
|
66
|
+
|
|
67
|
+
@runner.store_trace(
|
|
68
|
+
type: :episodic,
|
|
69
|
+
content_payload: message,
|
|
70
|
+
domain_tags: ['error', component.downcase],
|
|
71
|
+
origin: :direct_experience,
|
|
72
|
+
emotional_valence: valence,
|
|
73
|
+
emotional_intensity: intensity,
|
|
74
|
+
unresolved: true,
|
|
75
|
+
confidence: 0.9
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
# Flush if cache-backed
|
|
79
|
+
store = @runner.send(:default_store)
|
|
80
|
+
store.flush if store.respond_to?(:flush)
|
|
81
|
+
rescue StandardError
|
|
82
|
+
# Never let trace creation break the logging pipeline
|
|
83
|
+
nil
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module Extensions
|
|
7
|
+
module Memory
|
|
8
|
+
module Helpers
|
|
9
|
+
# In-memory store for development and testing.
|
|
10
|
+
# Production deployments should use a PostgreSQL + Redis backed store.
|
|
11
|
+
class Store
|
|
12
|
+
attr_reader :traces, :associations
|
|
13
|
+
|
|
14
|
+
def initialize
|
|
15
|
+
@traces = {}
|
|
16
|
+
@associations = Hash.new { |h, k| h[k] = Hash.new(0) }
|
|
17
|
+
load_from_local
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def store(trace)
|
|
21
|
+
@traces[trace[:trace_id]] = trace
|
|
22
|
+
trace[:trace_id]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def get(trace_id)
|
|
26
|
+
@traces[trace_id]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def delete(trace_id)
|
|
30
|
+
@traces.delete(trace_id)
|
|
31
|
+
@associations.delete(trace_id)
|
|
32
|
+
@associations.each_value { |links| links.delete(trace_id) }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def retrieve_by_type(type, min_strength: 0.0, limit: 100)
|
|
36
|
+
@traces.values
|
|
37
|
+
.select { |t| t[:trace_type] == type && t[:strength] >= min_strength }
|
|
38
|
+
.sort_by { |t| -t[:strength] }
|
|
39
|
+
.first(limit)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def retrieve_by_domain(domain_tag, min_strength: 0.0, limit: 100)
|
|
43
|
+
@traces.values
|
|
44
|
+
.select { |t| t[:domain_tags].include?(domain_tag) && t[:strength] >= min_strength }
|
|
45
|
+
.sort_by { |t| -t[:strength] }
|
|
46
|
+
.first(limit)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def retrieve_associated(trace_id, min_strength: 0.0, limit: 20)
|
|
50
|
+
trace = @traces[trace_id]
|
|
51
|
+
return [] unless trace
|
|
52
|
+
|
|
53
|
+
trace[:associated_traces]
|
|
54
|
+
.filter_map { |id| @traces[id] }
|
|
55
|
+
.select { |t| t[:strength] >= min_strength }
|
|
56
|
+
.sort_by { |t| -t[:strength] }
|
|
57
|
+
.first(limit)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def record_coactivation(trace_id_a, trace_id_b)
|
|
61
|
+
return if trace_id_a == trace_id_b
|
|
62
|
+
|
|
63
|
+
@associations[trace_id_a][trace_id_b] += 1
|
|
64
|
+
@associations[trace_id_b][trace_id_a] += 1
|
|
65
|
+
|
|
66
|
+
threshold = Helpers::Trace::COACTIVATION_THRESHOLD
|
|
67
|
+
|
|
68
|
+
return unless @associations[trace_id_a][trace_id_b] >= threshold
|
|
69
|
+
|
|
70
|
+
link_traces(trace_id_a, trace_id_b)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def all_traces(min_strength: 0.0)
|
|
74
|
+
@traces.values.select { |t| t[:strength] >= min_strength }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def count
|
|
78
|
+
@traces.size
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def firmware_traces
|
|
82
|
+
retrieve_by_type(:firmware)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def walk_associations(start_id:, max_hops: 12, min_strength: 0.1)
|
|
86
|
+
return [] unless @traces.key?(start_id)
|
|
87
|
+
|
|
88
|
+
results = []
|
|
89
|
+
visited = Set.new([start_id])
|
|
90
|
+
queue = [[start_id, 0, [start_id]]]
|
|
91
|
+
|
|
92
|
+
until queue.empty?
|
|
93
|
+
current_id, depth, path = queue.shift
|
|
94
|
+
next unless (current = @traces[current_id])
|
|
95
|
+
|
|
96
|
+
current[:associated_traces].each do |neighbor_id|
|
|
97
|
+
next if visited.include?(neighbor_id)
|
|
98
|
+
|
|
99
|
+
neighbor = @traces[neighbor_id]
|
|
100
|
+
next unless neighbor
|
|
101
|
+
next unless neighbor[:strength] >= min_strength
|
|
102
|
+
|
|
103
|
+
visited << neighbor_id
|
|
104
|
+
neighbor_path = path + [neighbor_id]
|
|
105
|
+
results << { trace_id: neighbor_id, depth: depth + 1, path: neighbor_path }
|
|
106
|
+
queue << [neighbor_id, depth + 1, neighbor_path] if depth + 1 < max_hops
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
results
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def save_to_local
|
|
114
|
+
return unless defined?(Legion::Data::Local) && Legion::Data::Local.connected?
|
|
115
|
+
return unless Legion::Data::Local.connection.table_exists?(:memory_traces)
|
|
116
|
+
|
|
117
|
+
db = Legion::Data::Local.connection
|
|
118
|
+
|
|
119
|
+
@traces.each_value do |trace|
|
|
120
|
+
row = serialize_trace_for_db(trace)
|
|
121
|
+
existing = db[:memory_traces].where(trace_id: trace[:trace_id]).first
|
|
122
|
+
if existing
|
|
123
|
+
db[:memory_traces].where(trace_id: trace[:trace_id]).update(row)
|
|
124
|
+
else
|
|
125
|
+
db[:memory_traces].insert(row)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
db_trace_ids = db[:memory_traces].select_map(:trace_id)
|
|
130
|
+
memory_trace_ids = @traces.keys
|
|
131
|
+
stale_ids = db_trace_ids - memory_trace_ids
|
|
132
|
+
db[:memory_traces].where(trace_id: stale_ids).delete unless stale_ids.empty?
|
|
133
|
+
|
|
134
|
+
db[:memory_associations].delete
|
|
135
|
+
@associations.each do |id_a, targets|
|
|
136
|
+
targets.each do |id_b, count|
|
|
137
|
+
db[:memory_associations].insert(trace_id_a: id_a, trace_id_b: id_b, coactivation_count: count)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def load_from_local
|
|
143
|
+
return unless defined?(Legion::Data::Local) && Legion::Data::Local.connected?
|
|
144
|
+
return unless Legion::Data::Local.connection.table_exists?(:memory_traces)
|
|
145
|
+
|
|
146
|
+
db = Legion::Data::Local.connection
|
|
147
|
+
|
|
148
|
+
db[:memory_traces].each do |row|
|
|
149
|
+
@traces[row[:trace_id]] = deserialize_trace_from_db(row)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
db[:memory_associations].each do |row|
|
|
153
|
+
@associations[row[:trace_id_a]] ||= {}
|
|
154
|
+
@associations[row[:trace_id_a]][row[:trace_id_b]] = row[:coactivation_count]
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
private
|
|
159
|
+
|
|
160
|
+
def serialize_trace_for_db(trace)
|
|
161
|
+
payload = trace[:content_payload] || trace[:content]
|
|
162
|
+
{
|
|
163
|
+
trace_id: trace[:trace_id],
|
|
164
|
+
trace_type: trace[:trace_type].to_s,
|
|
165
|
+
content: payload.is_a?(Hash) ? ::JSON.generate(payload) : payload.to_s,
|
|
166
|
+
strength: trace[:strength],
|
|
167
|
+
peak_strength: trace[:peak_strength],
|
|
168
|
+
base_decay_rate: trace[:base_decay_rate],
|
|
169
|
+
emotional_valence: trace[:emotional_valence].is_a?(Hash) ? ::JSON.generate(trace[:emotional_valence]) : nil,
|
|
170
|
+
emotional_intensity: trace[:emotional_intensity],
|
|
171
|
+
domain_tags: trace[:domain_tags].is_a?(Array) ? ::JSON.generate(trace[:domain_tags]) : nil,
|
|
172
|
+
origin: trace[:origin].to_s,
|
|
173
|
+
created_at: trace[:created_at],
|
|
174
|
+
last_reinforced: trace[:last_reinforced],
|
|
175
|
+
last_decayed: trace[:last_decayed],
|
|
176
|
+
reinforcement_count: trace[:reinforcement_count],
|
|
177
|
+
confidence: trace[:confidence],
|
|
178
|
+
storage_tier: trace[:storage_tier].to_s,
|
|
179
|
+
partition_id: trace[:partition_id],
|
|
180
|
+
associated_traces: trace[:associated_traces].is_a?(Array) ? ::JSON.generate(trace[:associated_traces]) : nil,
|
|
181
|
+
parent_id: trace[:parent_trace_id] || trace[:parent_id],
|
|
182
|
+
child_ids: (trace[:child_trace_ids] || trace[:child_ids]).then do |v|
|
|
183
|
+
v.is_a?(Array) ? ::JSON.generate(v) : nil
|
|
184
|
+
end,
|
|
185
|
+
unresolved: trace[:unresolved] || false,
|
|
186
|
+
consolidation_candidate: trace[:consolidation_candidate] || false
|
|
187
|
+
}
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def deserialize_trace_from_db(row)
|
|
191
|
+
content_raw = row[:content]
|
|
192
|
+
content = begin
|
|
193
|
+
parsed = ::JSON.parse(content_raw, symbolize_names: true)
|
|
194
|
+
parsed.is_a?(Hash) ? parsed : content_raw
|
|
195
|
+
rescue StandardError
|
|
196
|
+
content_raw
|
|
197
|
+
end
|
|
198
|
+
{
|
|
199
|
+
trace_id: row[:trace_id],
|
|
200
|
+
trace_type: row[:trace_type]&.to_sym,
|
|
201
|
+
content_payload: content,
|
|
202
|
+
content: content,
|
|
203
|
+
strength: row[:strength],
|
|
204
|
+
peak_strength: row[:peak_strength],
|
|
205
|
+
base_decay_rate: row[:base_decay_rate],
|
|
206
|
+
emotional_valence: begin
|
|
207
|
+
::JSON.parse(row[:emotional_valence], symbolize_names: true)
|
|
208
|
+
rescue StandardError
|
|
209
|
+
0.0
|
|
210
|
+
end,
|
|
211
|
+
emotional_intensity: row[:emotional_intensity],
|
|
212
|
+
domain_tags: begin
|
|
213
|
+
::JSON.parse(row[:domain_tags])
|
|
214
|
+
rescue StandardError
|
|
215
|
+
[]
|
|
216
|
+
end,
|
|
217
|
+
origin: row[:origin]&.to_sym,
|
|
218
|
+
created_at: row[:created_at],
|
|
219
|
+
last_reinforced: row[:last_reinforced],
|
|
220
|
+
last_decayed: row[:last_decayed],
|
|
221
|
+
reinforcement_count: row[:reinforcement_count],
|
|
222
|
+
confidence: row[:confidence],
|
|
223
|
+
storage_tier: row[:storage_tier]&.to_sym,
|
|
224
|
+
partition_id: row[:partition_id],
|
|
225
|
+
associated_traces: begin
|
|
226
|
+
::JSON.parse(row[:associated_traces])
|
|
227
|
+
rescue StandardError
|
|
228
|
+
[]
|
|
229
|
+
end,
|
|
230
|
+
parent_trace_id: row[:parent_id],
|
|
231
|
+
child_trace_ids: begin
|
|
232
|
+
::JSON.parse(row[:child_ids])
|
|
233
|
+
rescue StandardError
|
|
234
|
+
[]
|
|
235
|
+
end,
|
|
236
|
+
unresolved: row[:unresolved] || false,
|
|
237
|
+
consolidation_candidate: row[:consolidation_candidate] || false
|
|
238
|
+
}
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def link_traces(id_a, id_b)
|
|
242
|
+
trace_a = @traces[id_a]
|
|
243
|
+
trace_b = @traces[id_b]
|
|
244
|
+
return unless trace_a && trace_b
|
|
245
|
+
|
|
246
|
+
max = Helpers::Trace::MAX_ASSOCIATIONS
|
|
247
|
+
trace_a[:associated_traces] << id_b unless trace_a[:associated_traces].include?(id_b) || trace_a[:associated_traces].size >= max
|
|
248
|
+
return if trace_b[:associated_traces].include?(id_a) || trace_b[:associated_traces].size >= max
|
|
249
|
+
|
|
250
|
+
trace_b[:associated_traces] << id_a
|
|
251
|
+
end
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module Extensions
|
|
7
|
+
module Memory
|
|
8
|
+
module Helpers
|
|
9
|
+
module Trace
|
|
10
|
+
TRACE_TYPES = %i[firmware identity procedural trust semantic episodic sensory].freeze
|
|
11
|
+
|
|
12
|
+
ORIGINS = %i[firmware direct_experience mesh_transfer imprint].freeze
|
|
13
|
+
|
|
14
|
+
STORAGE_TIERS = %i[hot warm cold erased].freeze
|
|
15
|
+
|
|
16
|
+
BASE_DECAY_RATES = {
|
|
17
|
+
firmware: 0.000,
|
|
18
|
+
identity: 0.001,
|
|
19
|
+
procedural: 0.005,
|
|
20
|
+
trust: 0.008,
|
|
21
|
+
semantic: 0.010,
|
|
22
|
+
episodic: 0.020,
|
|
23
|
+
sensory: 0.100
|
|
24
|
+
}.freeze
|
|
25
|
+
|
|
26
|
+
STARTING_STRENGTHS = {
|
|
27
|
+
firmware: 1.000,
|
|
28
|
+
identity: 1.000,
|
|
29
|
+
procedural: 0.400,
|
|
30
|
+
trust: 0.300,
|
|
31
|
+
semantic: 0.500,
|
|
32
|
+
episodic: 0.600,
|
|
33
|
+
sensory: 0.400
|
|
34
|
+
}.freeze
|
|
35
|
+
|
|
36
|
+
# Tuning constants from spec Section 4.5
|
|
37
|
+
E_WEIGHT = 0.3 # emotional intensity weight on decay
|
|
38
|
+
R_AMOUNT = 0.10 # base reinforcement amount
|
|
39
|
+
IMPRINT_MULTIPLIER = 3.0 # reinforcement boost during imprint window
|
|
40
|
+
AUTO_FIRE_THRESHOLD = 0.85 # procedural auto-fire strength threshold
|
|
41
|
+
ARCHIVE_THRESHOLD = 0.05 # below this, trace moves to cold storage
|
|
42
|
+
PRUNE_THRESHOLD = 0.01 # below this, trace eligible for removal
|
|
43
|
+
HOT_TIER_WINDOW = 86_400 # 24 hours in seconds
|
|
44
|
+
WARM_TIER_WINDOW = 7_776_000 # 90 days in seconds
|
|
45
|
+
RETRIEVAL_RECENCY_HALF = 3600 # half-life for recency scoring (1 hour)
|
|
46
|
+
ASSOCIATION_BONUS = 0.15 # bonus for Hebbian-associated traces
|
|
47
|
+
MAX_ASSOCIATIONS = 20 # max Hebbian links per trace
|
|
48
|
+
COACTIVATION_THRESHOLD = 3 # co-activations before Hebbian link forms
|
|
49
|
+
|
|
50
|
+
module_function
|
|
51
|
+
|
|
52
|
+
def new_trace(type:, content_payload:, content_embedding: nil, emotional_valence: 0.0, # rubocop:disable Metrics/ParameterLists
|
|
53
|
+
emotional_intensity: 0.0, domain_tags: [], origin: :direct_experience,
|
|
54
|
+
source_agent_id: nil, partition_id: nil, imprint_active: false,
|
|
55
|
+
unresolved: false, consolidation_candidate: false, confidence: nil, **)
|
|
56
|
+
raise ArgumentError, "invalid trace type: #{type}" unless TRACE_TYPES.include?(type)
|
|
57
|
+
raise ArgumentError, "invalid origin: #{origin}" unless ORIGINS.include?(origin)
|
|
58
|
+
|
|
59
|
+
now = Time.now.utc
|
|
60
|
+
|
|
61
|
+
{
|
|
62
|
+
trace_id: SecureRandom.uuid,
|
|
63
|
+
trace_type: type,
|
|
64
|
+
content_embedding: content_embedding,
|
|
65
|
+
content_payload: content_payload,
|
|
66
|
+
strength: STARTING_STRENGTHS[type],
|
|
67
|
+
peak_strength: STARTING_STRENGTHS[type],
|
|
68
|
+
base_decay_rate: BASE_DECAY_RATES[type],
|
|
69
|
+
emotional_valence: emotional_valence.clamp(-1.0, 1.0),
|
|
70
|
+
emotional_intensity: emotional_intensity.clamp(0.0, 1.0),
|
|
71
|
+
domain_tags: Array(domain_tags),
|
|
72
|
+
origin: origin,
|
|
73
|
+
source_agent_id: source_agent_id,
|
|
74
|
+
created_at: now,
|
|
75
|
+
last_reinforced: now,
|
|
76
|
+
last_decayed: now,
|
|
77
|
+
reinforcement_count: imprint_active ? 1 : 0,
|
|
78
|
+
confidence: confidence || (type == :firmware ? 1.0 : 0.5),
|
|
79
|
+
storage_tier: :hot,
|
|
80
|
+
partition_id: partition_id,
|
|
81
|
+
encryption_key_id: nil,
|
|
82
|
+
associated_traces: [],
|
|
83
|
+
parent_trace_id: nil,
|
|
84
|
+
child_trace_ids: [],
|
|
85
|
+
unresolved: unresolved,
|
|
86
|
+
consolidation_candidate: consolidation_candidate
|
|
87
|
+
}
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def valid_trace?(trace)
|
|
91
|
+
return false unless trace.is_a?(Hash)
|
|
92
|
+
return false unless TRACE_TYPES.include?(trace[:trace_type])
|
|
93
|
+
return false unless trace[:strength].is_a?(Numeric)
|
|
94
|
+
return false unless trace[:strength].between?(0.0, 1.0)
|
|
95
|
+
|
|
96
|
+
true
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|