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.
@@ -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