engram 0.5.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.
@@ -49,8 +49,9 @@ module Engram
49
49
  next []
50
50
  end
51
51
 
52
- decisions = consolidate(candidates: candidates, scope: scope)
53
- applied_decisions = decisions.filter_map { |decision| apply(decision, scope) }
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) }
54
55
  payload[:decision_count] = applied_decisions.size
55
56
  payload[:decision_actions] = applied_decisions.map { |decision| decision.action.to_s }
56
57
  complete_claim(scope, idempotency_key, claim)
@@ -79,36 +80,288 @@ module Engram
79
80
  def extract(messages:, scope:)
80
81
  payload = Engram::Instrumentation.payload(scope: scope, store: @store, message_count: messages.size)
81
82
  Engram::Instrumentation.instrument("extract", payload) do
82
- candidates = @extractor.extract(messages: messages, scope: scope)
83
+ candidates = normalize_extractions(@extractor.extract(messages: messages, scope: scope))
83
84
  payload[:candidate_count] = candidates.size
84
85
  candidates
85
86
  end
86
87
  end
87
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
+
88
121
  def consolidate(candidates:, scope:)
89
122
  payload = Engram::Instrumentation.payload(scope: scope, store: @store, candidate_count: candidates.size)
90
123
  Engram::Instrumentation.instrument("consolidate", payload) do
91
- 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
+
92
131
  payload[:decision_count] = decisions.size
93
132
  payload[:decision_actions] = decisions.map { |decision| decision.action.to_s }
94
- decisions
133
+ [decisions, candidate_budget, detached_by_candidate]
95
134
  end
96
135
  end
97
136
 
98
- def apply(decision, scope)
99
- case decision.action
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"
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"
224
+ end
225
+
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
100
335
  when :add
101
- raise Engram::Error, "cannot move memory across scopes" unless decision.candidate.scope == scope
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
102
342
 
103
- decision if persistence.add(decision.candidate, scope: scope)
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
353
+ case decision.action
354
+ when :add
355
+ result_decision if prepared && persistence.add_prepared(prepared, scope: scope)
104
356
  when :update
105
- if decision.target_id && persistence.update(scope: scope, id: decision.target_id, record: decision.candidate)
106
- decision
357
+ if decision.target_id && prepared &&
358
+ persistence.update_prepared(scope: scope, id: decision.target_id, record: prepared)
359
+ result_decision
107
360
  end
108
361
  when :forget
109
- if decision.target_id
362
+ if decision.target_id && prepared
110
363
  deleted = @store.delete(scope: scope, id: decision.target_id)
111
- decision if deleted && deleted != 0
364
+ result_decision if deleted && deleted != 0
112
365
  end
113
366
  when :noop
114
367
  nil
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Engram
4
+ module UseCases
5
+ # Look up which memories in a scope reference a given host source.
6
+ #
7
+ # Returns the records whose understood provenance lists a source matching both
8
+ # `source_id` and `source_type` exactly, with no trimming or normalization.
9
+ # Reads stay tolerant: legacy, malformed, and future-schema
10
+ # provenance simply do not match. Source IDs are references, not authorization
11
+ # boundaries, so callers still bound the lookup to a `scope`.
12
+ class SourceImpact
13
+ def initialize(store:)
14
+ @store = store
15
+ end
16
+
17
+ # Returns the Array<Record> in `scope` referencing the exact source. Result
18
+ # order follows the store's #all enumeration and is not otherwise guaranteed.
19
+ def call(scope:, source_id:, source_type:)
20
+ source_id = require_string!("source_id", source_id)
21
+ source_type = require_string!("source_type", source_type)
22
+
23
+ @store.all(scope: scope).select do |record|
24
+ provenance = record.provenance
25
+ provenance&.sources&.any? do |source|
26
+ source.source_id == source_id && source.source_type == source_type
27
+ end
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ def require_string!(name, value)
34
+ unless value.is_a?(String) && !value.strip.empty?
35
+ raise ArgumentError, "#{name} must be a non-empty String"
36
+ end
37
+
38
+ value
39
+ end
40
+ end
41
+ end
42
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Engram
4
- VERSION = "0.5.0"
4
+ VERSION = "0.6.0"
5
5
  end
data/lib/engram.rb CHANGED
@@ -3,11 +3,15 @@
3
3
  require_relative "engram/version"
4
4
  require_relative "engram/configuration"
5
5
  require_relative "engram/math"
6
+ require_relative "engram/internal/core_hash"
6
7
  require_relative "engram/reserved_metadata"
7
8
  require_relative "engram/embedding_metadata"
8
9
  require_relative "engram/provenance"
9
10
  require_relative "engram/memory_kind"
10
11
  require_relative "engram/record"
12
+ require_relative "engram/internal/scope"
13
+ require_relative "engram/internal/candidate_integrity"
14
+ require_relative "engram/extraction"
11
15
  require_relative "engram/decision"
12
16
  require_relative "engram/turn_digest"
13
17
  require_relative "engram/persistence_policy"
@@ -28,6 +32,8 @@ require_relative "engram/use_cases/inject"
28
32
  require_relative "engram/use_cases/observe"
29
33
  require_relative "engram/use_cases/forget"
30
34
  require_relative "engram/use_cases/rebuild_embeddings"
35
+ require_relative "engram/use_cases/source_impact"
36
+ require_relative "engram/use_cases/grounding_report"
31
37
 
32
38
  # Built-in adapters (pure Ruby, no external deps)
33
39
  require_relative "engram/adapters/in_memory_store"
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: engram
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Alexandr Kholodniak
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-07-16 00:00:00.000000000 Z
11
+ date: 2026-07-28 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: |
14
14
  Engram gives AI agents durable, long-term memory. It recalls relevant facts about a
@@ -37,9 +37,13 @@ files:
37
37
  - lib/engram/consolidators/llm_consolidator.rb
38
38
  - lib/engram/decision.rb
39
39
  - lib/engram/embedding_metadata.rb
40
+ - lib/engram/extraction.rb
40
41
  - lib/engram/extractors/llm_extractor.rb
41
42
  - lib/engram/instrumentation.rb
42
43
  - lib/engram/integrations/ruby_llm.rb
44
+ - lib/engram/internal/candidate_integrity.rb
45
+ - lib/engram/internal/core_hash.rb
46
+ - lib/engram/internal/scope.rb
43
47
  - lib/engram/math.rb
44
48
  - lib/engram/memory.rb
45
49
  - lib/engram/memory_kind.rb
@@ -61,10 +65,12 @@ files:
61
65
  - lib/engram/reserved_metadata.rb
62
66
  - lib/engram/turn_digest.rb
63
67
  - lib/engram/use_cases/forget.rb
68
+ - lib/engram/use_cases/grounding_report.rb
64
69
  - lib/engram/use_cases/inject.rb
65
70
  - lib/engram/use_cases/observe.rb
66
71
  - lib/engram/use_cases/rebuild_embeddings.rb
67
72
  - lib/engram/use_cases/recall.rb
73
+ - lib/engram/use_cases/source_impact.rb
68
74
  - lib/engram/version.rb
69
75
  - lib/generators/engram/install_generator.rb
70
76
  - lib/generators/engram/templates/create_engram_memories.rb.tt