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,588 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Engram
4
+ # Provider-neutral source provenance stored in Record metadata.
5
+ #
6
+ # Offsets are zero-based Unicode codepoint indexes into one host-owned source
7
+ # message. The end offset is exclusive. Engram stores references and spans,
8
+ # never the source text itself.
9
+ class Provenance
10
+ RESERVED_KEY = "_engram"
11
+ METADATA_KEY = "provenance"
12
+ SCHEMA_VERSION = 1
13
+ ALIGNMENTS = %i[exact normalized inferred ungrounded].freeze
14
+ # Includes arbitrary extension fields. Keeping this conservative leaves ample
15
+ # Ruby stack headroom while covering the schema's ordinary nesting.
16
+ MAX_PROVENANCE_NESTING = 100
17
+ private_constant :MAX_PROVENANCE_NESTING
18
+
19
+ class Span
20
+ OFFSET_UNIT = "unicode_codepoint"
21
+
22
+ attr_reader :start_offset, :end_offset, :offset_unit
23
+
24
+ def initialize(start_offset:, end_offset:, offset_unit: OFFSET_UNIT)
25
+ unless start_offset.is_a?(Integer) && end_offset.is_a?(Integer) &&
26
+ start_offset >= 0 && end_offset > start_offset
27
+ raise ArgumentError, "span offsets must be non-negative integers with end_offset > start_offset"
28
+ end
29
+ raise ArgumentError, "unsupported offset unit" unless offset_unit.to_s == OFFSET_UNIT
30
+
31
+ @start_offset = start_offset
32
+ @end_offset = end_offset
33
+ @offset_unit = OFFSET_UNIT
34
+ freeze
35
+ end
36
+
37
+ def end_exclusive? = true
38
+
39
+ def ==(other)
40
+ other.is_a?(self.class) && to_h == other.to_h
41
+ end
42
+ alias_method :eql?, :==
43
+
44
+ def hash = to_h.hash
45
+
46
+ def to_h
47
+ {"start_offset" => start_offset, "end_offset" => end_offset, "offset_unit" => offset_unit}
48
+ end
49
+ end
50
+
51
+ class SourceText
52
+ attr_reader :source_id, :source_type, :message_index, :role, :text
53
+
54
+ def initialize(source_id:, source_type:, message_index:, role:, text:)
55
+ raise ArgumentError, "source_id is required" if source_id.to_s.strip.empty?
56
+ raise ArgumentError, "source_type is required" if source_type.to_s.strip.empty?
57
+ unless message_index.is_a?(Integer) && !message_index.negative?
58
+ raise ArgumentError, "message_index must be a non-negative integer"
59
+ end
60
+ raise ArgumentError, "role is required" if role.to_s.strip.empty?
61
+ raise ArgumentError, "source text must be a String" unless text.is_a?(String)
62
+ raise ArgumentError, "source text must be valid UTF-8" unless text.valid_encoding?
63
+
64
+ @source_id = source_id.to_s.dup.freeze
65
+ @source_type = source_type.to_s.dup.freeze
66
+ @message_index = message_index
67
+ @role = role.to_s.dup.freeze
68
+ @text = text.encode(Encoding::UTF_8).freeze
69
+ freeze
70
+ rescue EncodingError
71
+ raise ArgumentError, "source text must be valid UTF-8"
72
+ end
73
+ end
74
+
75
+ class Source
76
+ attr_reader :source_id, :source_type, :message_index, :role, :spans, :alignment
77
+
78
+ def initialize(source_id:, source_type:, message_index:, role:, spans:, alignment:)
79
+ raise ArgumentError, "source_id is required" if source_id.to_s.strip.empty?
80
+ raise ArgumentError, "source_type is required" if source_type.to_s.strip.empty?
81
+ unless message_index.is_a?(Integer) && !message_index.negative?
82
+ raise ArgumentError, "message_index must be a non-negative integer"
83
+ end
84
+ raise ArgumentError, "role is required" if role.to_s.strip.empty?
85
+
86
+ unless alignment.is_a?(String) || alignment.is_a?(Symbol)
87
+ raise ArgumentError, "unknown alignment #{alignment.inspect}"
88
+ end
89
+ normalized_alignment = alignment.to_sym
90
+ raise ArgumentError, "unknown alignment #{alignment.inspect}" unless ALIGNMENTS.include?(normalized_alignment)
91
+
92
+ @source_id = source_id.to_s.dup.freeze
93
+ @source_type = source_type.to_s.dup.freeze
94
+ @message_index = message_index
95
+ @role = role.to_s.dup.freeze
96
+ raise ArgumentError, "spans must be an array" unless spans.is_a?(Array)
97
+ raise ArgumentError, "spans must contain at least one span" if spans.empty?
98
+ @spans = spans.map do |span|
99
+ raise ArgumentError, "spans must contain Provenance::Span values" unless span.is_a?(Span)
100
+ span
101
+ end.freeze
102
+ @alignment = normalized_alignment
103
+ freeze
104
+ end
105
+
106
+ def validate_source_text!(source_text)
107
+ unless source_text.is_a?(SourceText)
108
+ raise ArgumentError, "source_text must be a Provenance::SourceText"
109
+ end
110
+
111
+ {
112
+ source_id: source_id,
113
+ source_type: source_type,
114
+ message_index: message_index,
115
+ role: role
116
+ }.each do |attribute, expected|
117
+ actual = source_text.public_send(attribute)
118
+ next if actual == expected
119
+
120
+ raise Engram::Error, "provenance #{attribute} does not match source text"
121
+ end
122
+
123
+ source_length = source_text.text.length
124
+ spans.each_with_index do |span, index|
125
+ next if span.end_offset <= source_length
126
+
127
+ raise Engram::Error,
128
+ "provenance span #{index} ends at #{span.end_offset} beyond source length #{source_length}"
129
+ end
130
+
131
+ true
132
+ end
133
+
134
+ def supporting_text(source_text)
135
+ validate_source_text!(source_text)
136
+
137
+ spans.map do |span|
138
+ source_text.text[span.start_offset...span.end_offset].freeze
139
+ end.freeze
140
+ end
141
+
142
+ def ==(other)
143
+ other.is_a?(self.class) && to_h == other.to_h
144
+ end
145
+ alias_method :eql?, :==
146
+
147
+ def hash = to_h.hash
148
+
149
+ def to_h
150
+ data = {
151
+ "source_id" => source_id,
152
+ "source_type" => source_type,
153
+ "spans" => spans.map(&:to_h),
154
+ "alignment" => alignment.to_s
155
+ }
156
+ data["message_index"] = message_index
157
+ data["role"] = role
158
+ data
159
+ end
160
+ end
161
+
162
+ class Extractor
163
+ attr_reader :name, :provider, :model
164
+
165
+ def initialize(name:, model:, provider: nil)
166
+ raise ArgumentError, "extractor name is required" if name.to_s.strip.empty?
167
+ raise ArgumentError, "extractor model is required" if model.to_s.strip.empty?
168
+
169
+ @name = name.to_s.dup.freeze
170
+ @provider = provider&.to_s&.dup&.freeze
171
+ @model = model.to_s.dup.freeze
172
+ freeze
173
+ end
174
+
175
+ def ==(other)
176
+ other.is_a?(self.class) && to_h == other.to_h
177
+ end
178
+ alias_method :eql?, :==
179
+
180
+ def hash = to_h.hash
181
+
182
+ def to_h
183
+ {"name" => name, "model" => model}.tap do |data|
184
+ data["provider"] = provider if provider
185
+ end
186
+ end
187
+ end
188
+
189
+ attr_reader :sources, :extractor, :confidence
190
+
191
+ def initialize(sources:, extractor:, confidence:)
192
+ raise ArgumentError, "sources must be an array" unless sources.is_a?(Array)
193
+ raise ArgumentError, "sources must contain at least one source" if sources.empty?
194
+ @sources = sources.map do |source|
195
+ raise ArgumentError, "sources must contain Provenance::Source values" unless source.is_a?(Source)
196
+ source
197
+ end.freeze
198
+ raise ArgumentError, "extractor must be a Provenance::Extractor" unless extractor.is_a?(Extractor)
199
+ valid_confidence = (confidence.is_a?(Integer) || confidence.is_a?(Float)) && confidence.between?(0, 1)
200
+ unless valid_confidence
201
+ raise ArgumentError, "confidence must be an Integer or Float between 0 and 1"
202
+ end
203
+
204
+ @extractor = extractor
205
+ @confidence = confidence
206
+ freeze
207
+ end
208
+
209
+ def ==(other)
210
+ other.is_a?(self.class) && to_h == other.to_h
211
+ end
212
+ alias_method :eql?, :==
213
+
214
+ def hash = to_h.hash
215
+
216
+ def to_h
217
+ {
218
+ "version" => SCHEMA_VERSION,
219
+ "sources" => sources.map(&:to_h),
220
+ "extractor" => extractor.to_h,
221
+ "confidence" => confidence
222
+ }
223
+ end
224
+
225
+ def ungrounded?
226
+ sources.any? { |source| source.alignment == :ungrounded }
227
+ end
228
+
229
+ class << self
230
+ def attach(metadata, provenance)
231
+ raise ArgumentError, "provenance must be a Provenance value" unless provenance.is_a?(self)
232
+
233
+ Engram::ReservedMetadata.attach(metadata, METADATA_KEY, provenance.to_h)
234
+ end
235
+
236
+ def extract(metadata)
237
+ extract_for_persistence(metadata)
238
+ rescue Engram::Error
239
+ nil
240
+ end
241
+
242
+ # Strict parser used by writers. Ordinary reads intentionally use #extract and
243
+ # tolerate malformed or future schemas, but an older writer must not silently
244
+ # persist provenance it cannot validate.
245
+ def extract_for_persistence(metadata)
246
+ data = canonical_payload_for_persistence(metadata)
247
+ data && from_h(data)
248
+ end
249
+
250
+ # Returns a behavior-free canonical copy of the complete validated payload,
251
+ # including extension fields which the value object intentionally does not expose.
252
+ def canonical_payload_for_persistence(metadata)
253
+ metadata ||= {}
254
+ return nil unless core_kind_of?(metadata, Hash)
255
+
256
+ reserved_values = core_hash_values(metadata, RESERVED_KEY, :_engram)
257
+ reserved_hashes = reserved_values.select { |value| core_kind_of?(value, Hash) }
258
+ provenance_values = reserved_hashes
259
+ .flat_map { |reserved| core_hash_values(reserved, METADATA_KEY, :provenance) }
260
+ if provenance_values.empty?
261
+ reject_non_hash_reserved_values!(reserved_values)
262
+ return nil
263
+ end
264
+
265
+ reserved = provenance_values.reduce({}) do |merged, value|
266
+ detached = detach_provenance_container(value)
267
+ Engram::ReservedMetadata.merge(merged, METADATA_KEY => detached)
268
+ end
269
+ data = reserved.fetch(METADATA_KEY)
270
+ unless data.is_a?(Hash)
271
+ raise Engram::Error, "malformed provenance at _engram.provenance: expected an object"
272
+ end
273
+ unless data.key?("version")
274
+ raise Engram::Error, "malformed provenance at _engram.provenance.version: version is required"
275
+ end
276
+ unless data["version"] == SCHEMA_VERSION
277
+ raise Engram::Error, "unsupported provenance version #{data["version"].inspect}"
278
+ end
279
+
280
+ # Validate the known schema while retaining the already detached extension data.
281
+ from_h(data)
282
+ reject_non_hash_reserved_values!(reserved_values)
283
+ data
284
+ end
285
+
286
+ # Replaces validated provenance aliases with the detached payload in a plain
287
+ # reserved namespace. Unrelated application metadata values remain untouched.
288
+ def canonical_metadata_for_persistence(metadata)
289
+ payload = canonical_payload_for_persistence(metadata)
290
+ return metadata unless payload
291
+
292
+ # Copy the core table without rehashing arbitrary application keys, then
293
+ # remove only the reserved aliases through bound Hash traversal.
294
+ application_metadata = Hash.instance_method(:transform_values).bind_call(metadata) { |value| value }
295
+ Hash.instance_method(:delete_if).bind_call(application_metadata) do |key, _value|
296
+ reserved_key_style(key)
297
+ end
298
+ string_reserved = []
299
+ symbol_reserved = []
300
+ Engram::Internal::CoreHash.each_pair(metadata) do |key, value|
301
+ case reserved_key_style(key)
302
+ when :string then string_reserved << value
303
+ when :symbol then symbol_reserved << value
304
+ end
305
+ end
306
+
307
+ reserved = (string_reserved + symbol_reserved).reduce({}) do |merged, value|
308
+ siblings = {}
309
+ Engram::Internal::CoreHash.each_pair(value) do |key, nested|
310
+ siblings[key] = nested unless provenance_key_alias?(key)
311
+ end
312
+ Engram::ReservedMetadata.merge(merged, Engram::ReservedMetadata.normalize(siblings))
313
+ end
314
+ application_metadata[RESERVED_KEY] = reserved.merge(METADATA_KEY => payload)
315
+ application_metadata
316
+ end
317
+
318
+ # Represents the complete canonical payload without relying on value #==, #eql?,
319
+ # or #hash implementations. Explicit structural and scalar tags preserve key,
320
+ # value, and container roles. Integers remain distinct from Floats, whose packed
321
+ # IEEE 754 bits preserve distinctions such as negative zero.
322
+ def canonical_integrity_representation_for_persistence(metadata)
323
+ data = canonical_payload_for_persistence(metadata)
324
+ data && provenance_integrity_representation(data)
325
+ end
326
+
327
+ private
328
+
329
+ def reject_non_hash_reserved_values!(reserved_values)
330
+ return if reserved_values.all? { |value| core_kind_of?(value, Hash) }
331
+
332
+ raise Engram::Error, "metadata key #{RESERVED_KEY.inspect} is reserved for Engram embedding metadata"
333
+ end
334
+
335
+ def reserved_key_style(key)
336
+ key_class = Object.instance_method(:class).bind_call(key)
337
+ return :symbol if key.equal?(:_engram) && key_class.equal?(Symbol)
338
+ :string if core_kind_of?(key, String) && String.instance_method(:==).bind_call(key, RESERVED_KEY)
339
+ rescue TypeError
340
+ nil
341
+ end
342
+
343
+ def provenance_key_alias?(key)
344
+ core_key_equal?(key, METADATA_KEY, :provenance)
345
+ end
346
+
347
+ # Finds trusted namespace keys through Hash's implementation rather than any
348
+ # behavior supplied by a Hash subclass or singleton class.
349
+ def core_hash_values(hash, string_key, symbol_key)
350
+ values = []
351
+ Engram::Internal::CoreHash.each_pair(hash) do |key, value|
352
+ values << value if core_key_equal?(key, string_key, symbol_key)
353
+ end
354
+ values
355
+ end
356
+
357
+ def core_key_equal?(key, string_key, symbol_key)
358
+ key_class = Object.instance_method(:class).bind_call(key)
359
+ return key.equal?(symbol_key) if key_class.equal?(Symbol)
360
+ return false unless core_kind_of?(key, String)
361
+
362
+ # Compare the stored String bytes without dispatching subclass #==/#eql?.
363
+ String.instance_method(:==).bind_call(key, string_key)
364
+ rescue TypeError
365
+ false
366
+ end
367
+
368
+ # Detaches the provenance subtree into plain containers and trusted primitive
369
+ # leaves. Container subclasses retain their stored contents, while Strings are
370
+ # exposed without subclass behavior and canonicalized to UTF-8. Other metadata
371
+ # is deliberately left untouched.
372
+ def detach_provenance_container(value, active = {}.compare_by_identity, depth = 0)
373
+ if core_kind_of?(value, Hash)
374
+ with_acyclic_container(value, active) do
375
+ copy = {}
376
+ Engram::Internal::CoreHash.each_pair(value) do |key, nested|
377
+ normalized_key = provenance_key(key)
378
+ normalized_value = detach_nested_provenance(nested, active, depth)
379
+ if copy.key?(normalized_key)
380
+ # Both collision inputs have already passed the same depth bound;
381
+ # merging them cannot introduce a deeper path than either input.
382
+ copy.replace(Engram::ReservedMetadata.merge(copy, normalized_key => normalized_value))
383
+ else
384
+ copy[normalized_key] = normalized_value
385
+ end
386
+ end
387
+ copy
388
+ end
389
+ elsif core_kind_of?(value, Array)
390
+ with_acyclic_container(value, active) do
391
+ copy = []
392
+ Array.instance_method(:each).bind_call(value) do |nested|
393
+ copy << detach_nested_provenance(nested, active, depth)
394
+ end
395
+ copy
396
+ end
397
+ else
398
+ canonical_provenance_scalar(value)
399
+ end
400
+ end
401
+
402
+ def detach_nested_provenance(value, active, parent_depth)
403
+ if parent_depth >= MAX_PROVENANCE_NESTING && provenance_container?(value)
404
+ raise Engram::Error,
405
+ "malformed provenance at _engram.provenance: nesting exceeds maximum depth of #{MAX_PROVENANCE_NESTING}"
406
+ end
407
+
408
+ detach_provenance_container(value, active, parent_depth + 1)
409
+ end
410
+
411
+ def provenance_container?(value)
412
+ core_kind_of?(value, Hash) || core_kind_of?(value, Array)
413
+ end
414
+
415
+ def canonical_provenance_scalar(value)
416
+ value_class = Object.instance_method(:class).bind_call(value)
417
+ return nil if value_class.equal?(NilClass)
418
+ return true if value_class.equal?(TrueClass)
419
+ return false if value_class.equal?(FalseClass)
420
+ return value if value_class.equal?(Symbol) || value_class.equal?(Integer)
421
+ if value_class.equal?(Float)
422
+ unless Float.instance_method(:finite?).bind_call(value)
423
+ raise Engram::Error, "malformed provenance: scalar values must be JSON-native primitives"
424
+ end
425
+
426
+ return value
427
+ end
428
+
429
+ if core_kind_of?(value, String)
430
+ # Bound String#to_s exposes a subclass's underlying string as an exact String;
431
+ # bound #dup then removes any singleton methods from an exact String value.
432
+ string = String.instance_method(:to_s).bind_call(value)
433
+ string = canonical_utf8_string(string)
434
+ unless string
435
+ raise Engram::Error, "malformed provenance: String values must have valid encoding"
436
+ end
437
+
438
+ return String.instance_method(:dup).bind_call(string)
439
+ end
440
+
441
+ raise Engram::Error, "malformed provenance: scalar values must be JSON-native primitives"
442
+ rescue TypeError
443
+ raise Engram::Error, "malformed provenance: scalar values must be JSON-native primitives"
444
+ end
445
+
446
+ def provenance_integrity_representation(value)
447
+ value_class = Object.instance_method(:class).bind_call(value)
448
+ if value_class.equal?(Hash)
449
+ fields = {}
450
+ Engram::Internal::CoreHash.each_pair(value) do |key, nested|
451
+ fields[key] = provenance_integrity_representation(nested)
452
+ end
453
+ return [:object, fields]
454
+ end
455
+ if value_class.equal?(Array)
456
+ entries = []
457
+ Array.instance_method(:each).bind_call(value) do |nested|
458
+ entries << provenance_integrity_representation(nested)
459
+ end
460
+ return [:array, entries]
461
+ end
462
+ return [:null] if value_class.equal?(NilClass)
463
+ return [:boolean, true] if value_class.equal?(TrueClass)
464
+ return [:boolean, false] if value_class.equal?(FalseClass)
465
+ if value_class.equal?(Integer)
466
+ return [:integer, Integer.instance_method(:to_s).bind_call(value)]
467
+ end
468
+ if value_class.equal?(Float)
469
+ bits = Array.instance_method(:pack).bind_call([value], "G")
470
+ return [:float, bits]
471
+ end
472
+ return [:symbol, Symbol.instance_method(:to_s).bind_call(value)] if value_class.equal?(Symbol)
473
+ return [:string, value] if value_class.equal?(String)
474
+
475
+ # canonical_payload_for_persistence has already rejected every other type.
476
+ raise Engram::Error, "malformed provenance: scalar values must be JSON-native primitives"
477
+ rescue TypeError
478
+ raise Engram::Error, "malformed provenance: scalar values must be JSON-native primitives"
479
+ end
480
+
481
+ def provenance_key(key)
482
+ key_class = Object.instance_method(:class).bind_call(key)
483
+ string = if key_class.equal?(Symbol)
484
+ Symbol.instance_method(:to_s).bind_call(key)
485
+ elsif core_kind_of?(key, String)
486
+ # Bound String#to_s returns the underlying bytes as an exact String without
487
+ # dispatching subclass behavior; #dup strips exact-String singleton methods.
488
+ String.instance_method(:to_s).bind_call(key)
489
+ else
490
+ raise Engram::Error, "malformed provenance: object keys must be String or Symbol values"
491
+ end
492
+ string = canonical_utf8_string(string)
493
+ unless string
494
+ raise Engram::Error, "malformed provenance: object keys must have valid encoding"
495
+ end
496
+
497
+ String.instance_method(:dup).bind_call(string)
498
+ rescue TypeError
499
+ raise Engram::Error, "malformed provenance: object keys must be String or Symbol values"
500
+ end
501
+
502
+ def canonical_utf8_string(string)
503
+ return unless String.instance_method(:valid_encoding?).bind_call(string)
504
+
505
+ String.instance_method(:encode).bind_call(string, Encoding::UTF_8)
506
+ rescue EncodingError
507
+ nil
508
+ end
509
+
510
+ def with_acyclic_container(value, active)
511
+ raise Engram::Error, "malformed provenance: cyclic containers are unsupported" if active.key?(value)
512
+
513
+ active[value] = true
514
+ yield
515
+ ensure
516
+ active.delete(value)
517
+ end
518
+
519
+ def core_kind_of?(value, klass)
520
+ Object.instance_method(:is_a?).bind_call(value, klass)
521
+ rescue TypeError
522
+ false
523
+ end
524
+
525
+ def from_h(data)
526
+ sources = provenance_array(data["sources"], "_engram.provenance.sources")
527
+ extractor_data = provenance_hash(data["extractor"], "_engram.provenance.extractor")
528
+
529
+ parsed_sources = sources.each_with_index.map do |source, source_index|
530
+ source_path = "_engram.provenance.sources[#{source_index}]"
531
+ source = provenance_hash(source, source_path)
532
+ spans = provenance_array(source["spans"], "#{source_path}.spans")
533
+ parsed_spans = spans.each_with_index.map do |span, span_index|
534
+ span_path = "#{source_path}.spans[#{span_index}]"
535
+ span = provenance_hash(span, span_path)
536
+ build_provenance_value(span_path) do
537
+ Span.new(
538
+ start_offset: span["start_offset"],
539
+ end_offset: span["end_offset"],
540
+ offset_unit: span["offset_unit"]
541
+ )
542
+ end
543
+ end
544
+
545
+ build_provenance_value(source_path) do
546
+ Source.new(
547
+ source_id: source["source_id"],
548
+ source_type: source["source_type"],
549
+ message_index: source["message_index"],
550
+ role: source["role"],
551
+ spans: parsed_spans,
552
+ alignment: source["alignment"]
553
+ )
554
+ end
555
+ end
556
+
557
+ extractor = build_provenance_value("_engram.provenance.extractor") do
558
+ Extractor.new(**symbolize_extractor(extractor_data))
559
+ end
560
+ build_provenance_value("_engram.provenance") do
561
+ new(sources: parsed_sources, extractor: extractor, confidence: data["confidence"])
562
+ end
563
+ end
564
+
565
+ def provenance_hash(value, path)
566
+ return value if value.is_a?(Hash)
567
+
568
+ raise Engram::Error, "malformed provenance at #{path}: expected an object"
569
+ end
570
+
571
+ def provenance_array(value, path)
572
+ return value if value.is_a?(Array)
573
+
574
+ raise Engram::Error, "malformed provenance at #{path}: expected an array"
575
+ end
576
+
577
+ def build_provenance_value(path)
578
+ yield
579
+ rescue ArgumentError, TypeError, KeyError, NoMethodError, EncodingError => error
580
+ raise Engram::Error, "malformed provenance at #{path}: #{error.message}"
581
+ end
582
+
583
+ def symbolize_extractor(data)
584
+ {name: data["name"], provider: data["provider"], model: data["model"]}
585
+ end
586
+ end
587
+ end
588
+ end
@@ -1,30 +1,71 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "digest"
4
+ require "securerandom"
5
+
3
6
  module Engram
4
7
  module Rails
5
- # ProcessedTurns backed by Rails.cache. Idempotency survives across processes and job
6
- # retries when a shared cache (e.g. Solid Cache) is configured.
8
+ # Claims use atomic unless-exist writes. Generic ActiveSupport caches do not expose
9
+ # compare-and-delete, so release leaves a claim in place until lease expiry and completion
10
+ # never deletes it.
7
11
  class CacheProcessedTurns
8
12
  include Engram::Ports::ProcessedTurns
9
13
 
10
- def initialize(namespace: "engram:processed_turns", ttl: 86_400)
14
+ class NonAtomicCacheError < Engram::Error; end
15
+ class CacheWriteError < Engram::Error; end
16
+
17
+ def initialize(namespace: "engram:processed_turns", ttl: 86_400, lease_ttl: 300, cache: ::Rails.cache)
11
18
  @namespace = namespace
12
19
  @ttl = ttl
20
+ @lease_ttl = lease_ttl
21
+ @cache = cache
22
+ end
23
+
24
+ def claim(scope:, key:)
25
+ return if completed?(scope: scope, key: key)
26
+ token = SecureRandom.uuid
27
+ result = @cache.write(claim_key(scope, key), token, expires_in: @lease_ttl, unless_exist: true)
28
+ unless result == true || result == false
29
+ raise NonAtomicCacheError, "cache backend must return true/false for atomic write(unless_exist: true)"
30
+ end
31
+ return unless result
32
+ return token unless completed?(scope: scope, key: key)
33
+
34
+ release(scope: scope, key: key, claim: token)
35
+ nil
13
36
  end
14
37
 
15
- def seen?(key)
16
- ::Rails.cache.exist?(cache_key(key))
38
+ def complete(scope:, key:, claim:)
39
+ written = @cache.write(completed_key(scope, key), true, expires_in: @ttl)
40
+ raise CacheWriteError, "cache backend failed to write completed observation" unless written
41
+ true
17
42
  end
18
43
 
19
- def record(key)
20
- ::Rails.cache.write(cache_key(key), true, expires_in: @ttl)
21
- key
44
+ def release(scope:, key:, claim:)
45
+ # A read followed by delete could delete a successor lease.
46
+ nil
47
+ end
48
+
49
+ def completed?(scope:, key:)
50
+ @cache.exist?(completed_key(scope, key))
22
51
  end
23
52
 
24
53
  private
25
54
 
26
- def cache_key(key)
27
- "#{@namespace}:#{key}"
55
+ def digest(scope, key)
56
+ encoded = [scope, key].each_with_object(+"") do |component, buffer|
57
+ bytes = component.to_s.encode(Encoding::UTF_8)
58
+ buffer << [bytes.bytesize].pack("N") << bytes
59
+ end
60
+ Digest::SHA256.hexdigest(encoded)
61
+ end
62
+
63
+ def claim_key(scope, key)
64
+ "#{@namespace}:claim:#{digest(scope, key)}"
65
+ end
66
+
67
+ def completed_key(scope, key)
68
+ "#{@namespace}:completed:#{digest(scope, key)}"
28
69
  end
29
70
  end
30
71
  end
@@ -4,6 +4,11 @@ module Engram
4
4
  # Background observation: runs extract → consolidate off the request path.
5
5
  # Defined only when ActiveJob is available (loaded via the Railtie).
6
6
  class ObserveJob < ActiveJob::Base
7
+ # Later retries must outlast the default claim lease.
8
+ retry_on Engram::ObservationInProgressError,
9
+ wait: ->(executions) { (executions**4) + 2 },
10
+ attempts: 8
11
+
7
12
  def perform(scope, messages)
8
13
  Engram::Memory.new(scope: scope).observe(messages)
9
14
  end