engram 0.4.0 → 0.6.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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +113 -0
  3. data/README.md +217 -11
  4. data/lib/engram/adapters/in_memory_processed_turns.rb +40 -8
  5. data/lib/engram/adapters/in_memory_store.rb +30 -11
  6. data/lib/engram/adapters/null_embedder.rb +9 -0
  7. data/lib/engram/adapters/pgvector_store.rb +50 -17
  8. data/lib/engram/adapters/ruby_llm_embedder.rb +45 -4
  9. data/lib/engram/consolidators/heuristic_consolidator.rb +7 -1
  10. data/lib/engram/consolidators/llm_consolidator.rb +37 -10
  11. data/lib/engram/embedding_metadata.rb +135 -0
  12. data/lib/engram/extraction.rb +30 -0
  13. data/lib/engram/extractors/llm_extractor.rb +4 -3
  14. data/lib/engram/internal/candidate_integrity.rb +510 -0
  15. data/lib/engram/internal/core_hash.rb +36 -0
  16. data/lib/engram/internal/scope.rb +31 -0
  17. data/lib/engram/memory.rb +28 -1
  18. data/lib/engram/persistence.rb +83 -8
  19. data/lib/engram/persistence_policy.rb +11 -1
  20. data/lib/engram/ports/consolidator.rb +7 -2
  21. data/lib/engram/ports/extractor.rb +1 -1
  22. data/lib/engram/ports/memory_store.rb +25 -7
  23. data/lib/engram/ports/processed_turns.rb +16 -8
  24. data/lib/engram/provenance.rb +588 -0
  25. data/lib/engram/rails/cache_processed_turns.rb +51 -10
  26. data/lib/engram/rails/observe_job.rb +5 -0
  27. data/lib/engram/rails/tasks.rake +26 -0
  28. data/lib/engram/railtie.rb +4 -0
  29. data/lib/engram/record.rb +12 -5
  30. data/lib/engram/reserved_metadata.rb +52 -0
  31. data/lib/engram/use_cases/forget.rb +6 -2
  32. data/lib/engram/use_cases/grounding_report.rb +44 -0
  33. data/lib/engram/use_cases/observe.rb +300 -26
  34. data/lib/engram/use_cases/rebuild_embeddings.rb +189 -0
  35. data/lib/engram/use_cases/recall.rb +12 -4
  36. data/lib/engram/use_cases/source_impact.rb +42 -0
  37. data/lib/engram/version.rb +1 -1
  38. data/lib/engram.rb +13 -0
  39. metadata +14 -3
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Loaded by the Railtie in Rails apps (where :environment boots the app and its
4
+ # initializers) and by the gem's own Rakefile (which defines a no-op :environment).
5
+ namespace :engram do
6
+ desc "Rebuild embeddings in a memory scope. Usage: bundle exec rake 'engram:rebuild_embeddings[user:1]'"
7
+ task :rebuild_embeddings, [:scope] => :environment do |_, args|
8
+ scope = args[:scope]
9
+ raise ArgumentError, "rebuild_embeddings requires a scope argument" if scope.to_s.empty?
10
+
11
+ stale_only = !%w[false 0 no].include?(ENV.fetch("STALE_ONLY", "true").downcase)
12
+ batch_size = Integer(ENV.fetch("BATCH_SIZE", "100"))
13
+ raise ArgumentError, "BATCH_SIZE must be greater than 0" unless batch_size.positive?
14
+
15
+ result = Engram::UseCases::RebuildEmbeddings.new(
16
+ store: Engram.config.store,
17
+ embedder: Engram.config.embedder
18
+ ).call(scope: scope, stale_only: stale_only, batch_size: batch_size)
19
+
20
+ puts "scope=#{result[:scope]} processed=#{result[:processed]} updated=#{result[:updated]} skipped=#{result[:skipped]} failed=#{result[:failed]}"
21
+
22
+ if result[:failed] > 0
23
+ abort "failed_ids=#{result[:failed_ids].join(",")}"
24
+ end
25
+ end
26
+ end
@@ -19,5 +19,9 @@ module Engram
19
19
  require "engram/rails/observe_job"
20
20
  end
21
21
  end
22
+
23
+ rake_tasks do
24
+ load File.expand_path("rails/tasks.rake", __dir__)
25
+ end
22
26
  end
23
27
  end
data/lib/engram/record.rb CHANGED
@@ -8,6 +8,10 @@ module Engram
8
8
  # `kind` is a memory type (fact / preference / instruction / episodic). The legacy
9
9
  # `semantic` kind is normalized to `fact` for compatibility with pre-1.0 records.
10
10
  class Record
11
+ STATE_READERS = %i[
12
+ id content scope embedding kind importance metadata created_at last_accessed_at
13
+ ].freeze
14
+
11
15
  attr_accessor :id, :last_accessed_at
12
16
  attr_reader :content, :embedding, :scope, :kind, :importance, :metadata,
13
17
  :created_at
@@ -29,12 +33,15 @@ module Engram
29
33
  self.class.new(**to_h.merge(attributes))
30
34
  end
31
35
 
36
+ # Return structured supporting-source metadata when this record carries a
37
+ # provenance schema understood by the installed Engram version.
38
+ # Legacy, malformed, and future-schema records remain readable and return nil.
39
+ def provenance
40
+ Engram::Provenance.extract(metadata)
41
+ end
42
+
32
43
  def to_h
33
- {
34
- id: id, content: content, scope: scope, embedding: embedding, kind: kind,
35
- importance: importance, metadata: metadata,
36
- created_at: created_at, last_accessed_at: last_accessed_at
37
- }
44
+ STATE_READERS.to_h { |reader| [reader, public_send(reader)] }
38
45
  end
39
46
  end
40
47
  end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Engram
4
+ # Collision-preserving operations for schemas sharing Engram's metadata namespace.
5
+ module ReservedMetadata
6
+ KEY = "_engram"
7
+
8
+ module_function
9
+
10
+ def attach(metadata, schema_key, value, namespace_description: "metadata")
11
+ metadata = (metadata || {}).dup
12
+ reserved_values = [metadata.delete(KEY), metadata.delete(:_engram)].compact
13
+ unless reserved_values.all? { |reserved| reserved.is_a?(Hash) }
14
+ raise Engram::Error, "metadata key #{KEY.inspect} is reserved for Engram #{namespace_description}"
15
+ end
16
+
17
+ reserved = reserved_values.reduce({}) do |merged, reserved_value|
18
+ siblings = reserved_value.reject { |key, _nested| key.to_s == schema_key }
19
+ merge(merged, normalize(siblings))
20
+ end
21
+ metadata.merge(KEY => reserved.merge(schema_key => normalize(value)))
22
+ end
23
+
24
+ def normalize(value, path = [])
25
+ return value.map { |nested| normalize(nested, path) } if value.is_a?(Array)
26
+ return value unless value.is_a?(Hash)
27
+
28
+ value.each_with_object({}) do |(key, nested), normalized|
29
+ string_key = key.to_s
30
+ normalized_value = normalize(nested, path + [string_key])
31
+ normalized[string_key] = if normalized.key?(string_key)
32
+ merge_value(normalized[string_key], normalized_value, path + [string_key])
33
+ else
34
+ normalized_value
35
+ end
36
+ end
37
+ end
38
+
39
+ def merge(left, right, path = [])
40
+ right.each_with_object(left.dup) do |(key, value), merged|
41
+ merged[key] = merged.key?(key) ? merge_value(merged[key], value, path + [key]) : value
42
+ end
43
+ end
44
+
45
+ def merge_value(left, right, path)
46
+ return merge(left, right, path) if left.is_a?(Hash) && right.is_a?(Hash)
47
+ return left if left == right
48
+
49
+ raise Engram::Error, "conflicting reserved metadata at #{path.join(".")}"
50
+ end
51
+ end
52
+ end
@@ -20,8 +20,12 @@ module Engram
20
20
  timestamp && timestamp < cutoff && record.importance.to_f < min_importance
21
21
  end
22
22
 
23
- stale.each { |record| @store.delete(id: record.id) if record.id }
24
- stale
23
+ stale.select do |record|
24
+ next false unless record.id
25
+
26
+ deleted = @store.delete(scope: scope, id: record.id)
27
+ deleted && deleted != 0
28
+ end
25
29
  end
26
30
  end
27
31
  end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Engram
4
+ module UseCases
5
+ # Count records in a scope by their weakest understood source alignment.
6
+ class GroundingReport
7
+ ALIGNMENT_RANK = Engram::Provenance::ALIGNMENTS.each_with_index.to_h.freeze
8
+ private_constant :ALIGNMENT_RANK
9
+
10
+ def initialize(store:)
11
+ @store = store
12
+ end
13
+
14
+ def call(scope:)
15
+ counts = {
16
+ exact: 0,
17
+ normalized: 0,
18
+ inferred: 0,
19
+ ungrounded: 0,
20
+ unattributed: 0,
21
+ total: 0
22
+ }
23
+
24
+ @store.all(scope: scope).each do |record|
25
+ provenance = record.provenance
26
+ alignment = provenance && weakest_alignment(provenance)
27
+ counts[alignment || :unattributed] += 1
28
+ counts[:total] += 1
29
+ end
30
+
31
+ counts.freeze
32
+ end
33
+
34
+ private
35
+
36
+ def weakest_alignment(provenance)
37
+ alignments = provenance.sources.map(&:alignment)
38
+ return unless alignments.all? { |alignment| ALIGNMENT_RANK.key?(alignment) }
39
+
40
+ alignments.max_by { |alignment| ALIGNMENT_RANK.fetch(alignment) }
41
+ end
42
+ end
43
+ end
44
+ end
@@ -26,69 +26,343 @@ module Engram
26
26
  idempotency_key_present: !idempotency_key.nil?
27
27
  )
28
28
  Engram::Instrumentation.instrument("observe", payload) do
29
- if already_processed?(idempotency_key)
29
+ claim = acquire_claim(scope, idempotency_key)
30
+ if idempotency_key && @processed_turns && !claim
31
+ unless @processed_turns.completed?(scope: scope, key: idempotency_key)
32
+ # A live, incomplete lease is retryable, not a completed duplicate.
33
+ raise Engram::ObservationInProgressError,
34
+ "observation for this turn is claimed but not completed; retry after the claim lease expires"
35
+ end
36
+
30
37
  payload[:skipped] = true
31
38
  payload[:candidate_count] = 0
32
39
  payload[:decision_count] = 0
33
40
  next []
34
41
  end
35
42
 
36
- candidates = extract(messages: messages, scope: scope)
37
- payload[:candidate_count] = candidates.size
38
- if candidates.empty?
39
- mark_processed(idempotency_key)
40
- payload[:decision_count] = 0
41
- next []
42
- end
43
+ begin
44
+ candidates = extract(messages: messages, scope: scope)
45
+ payload[:candidate_count] = candidates.size
46
+ if candidates.empty?
47
+ complete_claim(scope, idempotency_key, claim)
48
+ payload[:decision_count] = 0
49
+ next []
50
+ end
43
51
 
44
- decisions = consolidate(candidates: candidates, scope: scope)
45
- applied_decisions = decisions.filter_map { |decision| apply(decision) }
46
- payload[:decision_count] = applied_decisions.size
47
- payload[:decision_actions] = applied_decisions.map { |decision| decision.action.to_s }
48
- mark_processed(idempotency_key)
49
- applied_decisions
52
+ decisions, candidate_budget, detached_by_candidate = consolidate(candidates: candidates, scope: scope)
53
+ operations = preflight(decisions, candidate_budget, detached_by_candidate, scope)
54
+ applied_decisions = operations.filter_map { |operation| apply(operation, scope) }
55
+ payload[:decision_count] = applied_decisions.size
56
+ payload[:decision_actions] = applied_decisions.map { |decision| decision.action.to_s }
57
+ complete_claim(scope, idempotency_key, claim)
58
+ applied_decisions
59
+ rescue
60
+ release_claim(scope, idempotency_key, claim)
61
+ raise
62
+ end
50
63
  end
51
64
  end
52
65
 
53
66
  private
54
67
 
55
- def already_processed?(key)
56
- !!(key && @processed_turns&.seen?(key))
68
+ def acquire_claim(scope, key)
69
+ @processed_turns.claim(scope: scope, key: key) if key && @processed_turns
57
70
  end
58
71
 
59
- def mark_processed(key)
60
- @processed_turns.record(key) if key && @processed_turns
72
+ def complete_claim(scope, key, claim)
73
+ @processed_turns.complete(scope: scope, key: key, claim: claim) if claim
74
+ end
75
+
76
+ def release_claim(scope, key, claim)
77
+ @processed_turns.release(scope: scope, key: key, claim: claim) if claim
61
78
  end
62
79
 
63
80
  def extract(messages:, scope:)
64
81
  payload = Engram::Instrumentation.payload(scope: scope, store: @store, message_count: messages.size)
65
82
  Engram::Instrumentation.instrument("extract", payload) do
66
- candidates = @extractor.extract(messages: messages, scope: scope)
83
+ candidates = normalize_extractions(@extractor.extract(messages: messages, scope: scope))
67
84
  payload[:candidate_count] = candidates.size
68
85
  candidates
69
86
  end
70
87
  end
71
88
 
89
+ def normalize_extractions(results)
90
+ unless core_array?(results)
91
+ raise Engram::Error, "extractor must return an Array containing only Engram::Record or Engram::Extraction values"
92
+ end
93
+
94
+ core_array_map(results) do |result|
95
+ candidate = case result
96
+ when Engram::Record then normalize_record(result)
97
+ when Engram::Extraction then normalize_record(result.to_record)
98
+ else
99
+ raise Engram::Error, "extractor must return an Array containing only Engram::Record or Engram::Extraction values"
100
+ end
101
+
102
+ raise Engram::Error, "observation candidates must not have an id" unless candidate.id.nil?
103
+
104
+ candidate
105
+ end
106
+ rescue Engram::Internal::CandidateIntegrity::Error => error
107
+ raise Engram::Error, error.message
108
+ end
109
+
110
+ def normalize_record(record)
111
+ if Object.instance_method(:instance_of?).bind_call(record, Engram::Record)
112
+ candidate_integrity.validate!(record)
113
+ return record
114
+ end
115
+
116
+ candidate_integrity.detach(record)
117
+ rescue Engram::Internal::CandidateIntegrity::Error => error
118
+ raise Engram::Error, error.message
119
+ end
120
+
72
121
  def consolidate(candidates:, scope:)
73
122
  payload = Engram::Instrumentation.payload(scope: scope, store: @store, candidate_count: candidates.size)
74
123
  Engram::Instrumentation.instrument("consolidate", payload) do
75
- decisions = @consolidator.reconcile_all(candidates: candidates, scope: scope)
124
+ candidate_snapshot = snapshot_candidates(candidates)
125
+ detached_candidates = detach_candidates(candidates)
126
+ candidate_budget, detached_by_candidate = candidate_context(candidates, detached_candidates)
127
+ raw_decisions = @consolidator.reconcile_all(candidates: candidates, scope: scope)
128
+ decisions = canonicalize_decisions(raw_decisions)
129
+ verify_candidates!(candidates, candidate_snapshot)
130
+
76
131
  payload[:decision_count] = decisions.size
77
132
  payload[:decision_actions] = decisions.map { |decision| decision.action.to_s }
78
- decisions
133
+ [decisions, candidate_budget, detached_by_candidate]
134
+ end
135
+ end
136
+
137
+ def snapshot_candidates(candidates)
138
+ candidate_integrity.snapshot(candidates)
139
+ rescue Engram::Internal::CandidateIntegrity::Error => error
140
+ raise Engram::Error, error.message
141
+ end
142
+
143
+ def verify_candidates!(candidates, snapshot)
144
+ candidate_integrity.verify!(candidates, snapshot)
145
+ rescue Engram::Internal::CandidateIntegrity::Error => error
146
+ raise Engram::Error, error.message
147
+ end
148
+
149
+ def detach_candidates(candidates)
150
+ candidates.map { |candidate| candidate_integrity.detach(candidate) }
151
+ rescue Engram::Internal::CandidateIntegrity::Error => error
152
+ raise Engram::Error, error.message
153
+ end
154
+
155
+ def candidate_context(candidates, detached_candidates)
156
+ candidate_budget = Hash.new(0).compare_by_identity
157
+ detached_by_candidate = {}.compare_by_identity
158
+ candidates.zip(detached_candidates) do |candidate, detached|
159
+ candidate_budget[candidate] += 1
160
+ (detached_by_candidate[candidate] ||= []) << detached
161
+ end
162
+ [candidate_budget, detached_by_candidate]
163
+ end
164
+
165
+ def candidate_integrity
166
+ @candidate_integrity ||= Engram::Internal::CandidateIntegrity.new
167
+ end
168
+
169
+ def canonicalize_decisions(raw_decisions)
170
+ unless core_array?(raw_decisions)
171
+ raise Engram::Error, "consolidator must return an Array of Engram::Decision values"
172
+ end
173
+
174
+ core_array_map(raw_decisions) do |raw_decision|
175
+ unless Object.instance_method(:is_a?).bind_call(raw_decision, Engram::Decision)
176
+ raise Engram::Error, "consolidator must return an Array of Engram::Decision values"
177
+ end
178
+
179
+ action = raw_decision.action
180
+ candidate = raw_decision.candidate
181
+ target_id = canonical_target_id(raw_decision.target_id)
182
+ reason = raw_decision.reason
183
+ unless Engram::Decision::ACTIONS.include?(action)
184
+ raise Engram::Error, "unsupported decision action #{action.inspect}"
185
+ end
186
+
187
+ Engram::Decision.new(action: action, candidate: candidate, target_id: target_id, reason: reason)
188
+ end
189
+ end
190
+
191
+ # Array subclasses are supported, but checks and traversal at an untrusted
192
+ # return boundary must not dispatch through singleton or subclass behavior.
193
+ def core_array?(value)
194
+ Object.instance_method(:is_a?).bind_call(value, Array)
195
+ rescue TypeError
196
+ false
197
+ end
198
+
199
+ def core_array_map(values)
200
+ mapped = []
201
+ Array.instance_method(:each).bind_call(values) { |value| mapped << yield(value) }
202
+ mapped
203
+ end
204
+
205
+ def canonical_target_id(target_id)
206
+ return if BasicObject.instance_method(:equal?).bind_call(target_id, nil)
207
+
208
+ target_class = Object.instance_method(:class).bind_call(target_id)
209
+ valid_class = target_class.equal?(Integer) || target_class.equal?(String)
210
+ valid_state = Object.instance_method(:instance_variables).bind_call(target_id).empty?
211
+ unless valid_class && valid_state
212
+ raise Engram::Error, "decision target_id must be a plain String or Integer"
79
213
  end
214
+
215
+ return target_id if target_class.equal?(Integer)
216
+
217
+ if custom_behavior?(target_id, target_class)
218
+ raise Engram::Error, "decision target_id must be a plain String or Integer"
219
+ end
220
+
221
+ String.instance_method(:dup).bind_call(target_id)
222
+ rescue TypeError
223
+ raise Engram::Error, "decision target_id must be a plain String or Integer"
80
224
  end
81
225
 
82
- def apply(decision)
226
+ def custom_behavior?(value, value_class)
227
+ singleton_class = Object.instance_method(:singleton_class).bind_call(value)
228
+ return false if singleton_class.equal?(value_class)
229
+
230
+ method_visibilities = %i[
231
+ public_instance_methods protected_instance_methods private_instance_methods
232
+ ]
233
+ return true if method_visibilities.any? do |visibility|
234
+ Module.instance_method(visibility).bind_call(singleton_class, false).any?
235
+ end
236
+
237
+ singleton_ancestors = Module.instance_method(:ancestors).bind_call(singleton_class)
238
+ class_ancestors = Module.instance_method(:ancestors).bind_call(value_class)
239
+ return true unless singleton_ancestors.length == class_ancestors.length + 1 &&
240
+ class_ancestors.each_with_index.all? { |ancestor, index| singleton_ancestors[index + 1].equal?(ancestor) }
241
+
242
+ method_visibilities.any? do |visibility|
243
+ singleton_methods = Module.instance_method(visibility).bind_call(singleton_class, true)
244
+ class_methods = Module.instance_method(visibility).bind_call(value_class, true)
245
+ singleton_methods.length != class_methods.length ||
246
+ singleton_methods.any? { |method_name| !class_methods.include?(method_name) }
247
+ end
248
+ end
249
+
250
+ def preflight(decisions, candidate_budget, detached_by_candidate, scope)
251
+ decision_counts = Hash.new(0).compare_by_identity
252
+ safe_decisions = []
253
+
254
+ decisions.each do |decision|
255
+ unless decision.is_a?(Engram::Decision)
256
+ raise Engram::Error, "consolidator must return an Array of Engram::Decision values"
257
+ end
258
+ unless Engram::Decision::ACTIONS.include?(decision.action)
259
+ raise Engram::Error, "unsupported decision action #{decision.action.inspect}"
260
+ end
261
+ unless Object.instance_method(:is_a?).bind_call(decision.candidate, Engram::Record)
262
+ message = if decision.action == :forget
263
+ "forget decision candidate must be an Engram::Record"
264
+ else
265
+ "decision candidate must be an Engram::Record"
266
+ end
267
+ raise Engram::Error, message
268
+ end
269
+ unless candidate_budget.key?(decision.candidate)
270
+ raise Engram::Error, "decision must reference the actual candidate supplied to the consolidator"
271
+ end
272
+ decision_counts[decision.candidate] += 1
273
+ if decision_counts[decision.candidate] > candidate_budget.fetch(decision.candidate)
274
+ raise Engram::Error,
275
+ "consolidator must return no more than one decision per candidate occurrence; " \
276
+ "multiple decisions reference the same candidate"
277
+ end
278
+ detached_candidate = detached_by_candidate.fetch(decision.candidate).fetch(
279
+ decision_counts.fetch(decision.candidate) - 1
280
+ )
281
+ unless Engram::Internal::Scope.record_matches?(detached_candidate, scope)
282
+ raise Engram::Error, "cannot move memory across scopes"
283
+ end
284
+
285
+ if %i[update forget].include?(decision.action) && !decision.target_id
286
+ raise Engram::Error, "#{decision.action} decision requires a target_id"
287
+ end
288
+
289
+ if %i[add update forget].include?(decision.action)
290
+ Engram::Provenance.extract_for_persistence(detached_candidate.metadata)
291
+ end
292
+ safe_decisions << Engram::Decision.new(action: decision.action, candidate: detached_candidate,
293
+ target_id: decision.target_id, reason: decision.reason)
294
+ end
295
+
296
+ preflight_destructive_targets!(safe_decisions, scope)
297
+ decisions.zip(safe_decisions).map do |result_decision, safe_decision|
298
+ prepare_operation(safe_decision, scope, result_decision)
299
+ end
300
+ end
301
+
302
+ def preflight_destructive_targets!(decisions, scope)
303
+ target_ids = decisions.filter_map do |decision|
304
+ decision.target_id if %i[update forget].include?(decision.action)
305
+ end
306
+ return if target_ids.empty?
307
+
308
+ duplicate_id = target_ids.tally.find { |_, count| count > 1 }&.first
309
+ if duplicate_id
310
+ raise Engram::Error, "multiple decisions target memory #{duplicate_id.inspect}"
311
+ end
312
+
313
+ existing_ids = scoped_existing_ids(scope, target_ids)
314
+ existing_id_lookup = existing_ids.each_with_object({}) { |id, lookup| lookup[id] = true }
315
+ missing_id = target_ids.find { |target_id| !existing_id_lookup[target_id] }
316
+ if missing_id
317
+ raise Engram::Error, "no memory with id #{missing_id.inspect} in scope #{scope.inspect}"
318
+ end
319
+ end
320
+
321
+ def scoped_existing_ids(scope, target_ids)
322
+ if @store.respond_to?(:existing_ids)
323
+ begin
324
+ return Array(@store.existing_ids(scope: scope, ids: target_ids))
325
+ rescue NotImplementedError
326
+ # Optional capability: legacy adapters may inherit the default stub.
327
+ end
328
+ end
329
+
330
+ @store.all(scope: scope).map(&:id)
331
+ end
332
+
333
+ def prepare_operation(decision, scope, result_decision)
334
+ prepared = case decision.action
335
+ when :add
336
+ persistence.prepare(decision.candidate)
337
+ when :update
338
+ persistence.prepare(decision.candidate) if decision.target_id
339
+ when :forget
340
+ persistence.allowed?(decision.candidate) if decision.target_id
341
+ end
342
+
343
+ if %i[add update].include?(decision.action) && prepared &&
344
+ !Engram::Internal::Scope.record_matches?(prepared, scope)
345
+ raise Engram::Error, "cannot move memory across scopes"
346
+ end
347
+
348
+ [result_decision, decision, prepared]
349
+ end
350
+
351
+ def apply(operation, scope)
352
+ result_decision, decision, prepared = operation
83
353
  case decision.action
84
354
  when :add
85
- decision if persistence.add(decision.candidate)
355
+ result_decision if prepared && persistence.add_prepared(prepared, scope: scope)
86
356
  when :update
87
- if decision.target_id && persistence.update(id: decision.target_id, record: decision.candidate)
88
- decision
357
+ if decision.target_id && prepared &&
358
+ persistence.update_prepared(scope: scope, id: decision.target_id, record: prepared)
359
+ result_decision
89
360
  end
90
361
  when :forget
91
- decision if decision.target_id && @store.delete(id: decision.target_id)
362
+ if decision.target_id && prepared
363
+ deleted = @store.delete(scope: scope, id: decision.target_id)
364
+ result_decision if deleted && deleted != 0
365
+ end
92
366
  when :noop
93
367
  nil
94
368
  end