engram 0.4.0 → 0.5.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.
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Engram
6
+ # Helpers for storing and validating embedding provenance under Engram's
7
+ # reserved metadata namespace.
8
+ module EmbeddingMetadata
9
+ RESERVED_KEY = "_engram"
10
+ EMBEDDING_KEY = "embedding"
11
+
12
+ module_function
13
+
14
+ def build(adapter:, dimensions:, model: nil, provider: nil)
15
+ data = {
16
+ "adapter" => adapter.to_s,
17
+ "dimensions" => dimensions
18
+ }
19
+ data["provider"] = provider.to_s if provider
20
+ data["model"] = model.to_s if model
21
+ data["fingerprint"] = fingerprint(data)
22
+ data
23
+ end
24
+
25
+ def for_embedder(embedder, embedding: nil)
26
+ return nil unless embedder.respond_to?(:embedding_metadata)
27
+
28
+ metadata = stringify_keys(embedder.embedding_metadata || {})
29
+ return nil if metadata.empty?
30
+
31
+ dimensions = embedding.respond_to?(:length) ? embedding.length : metadata["dimensions"]
32
+ metadata = metadata.merge("dimensions" => dimensions) if dimensions
33
+ metadata["fingerprint"] = fingerprint(metadata)
34
+ metadata
35
+ end
36
+
37
+ def attach(record, embedder: nil, embedding_metadata: nil)
38
+ metadata = stringify_keys(embedding_metadata || for_embedder(embedder, embedding: record.embedding)) || {}
39
+ return record if metadata.empty?
40
+
41
+ record.with(metadata: merge(record.metadata, metadata))
42
+ end
43
+
44
+ def extract(metadata)
45
+ metadata = stringify_keys(metadata || {})
46
+ reserved = metadata[RESERVED_KEY]
47
+ return {} unless reserved.is_a?(Hash)
48
+
49
+ stringify_keys(reserved[EMBEDDING_KEY] || {})
50
+ end
51
+
52
+ def merge(metadata, embedding_metadata)
53
+ Engram::ReservedMetadata.attach(
54
+ metadata, EMBEDDING_KEY, embedding_metadata || {}, namespace_description: "embedding metadata"
55
+ )
56
+ end
57
+
58
+ def search(store, embedding:, embedding_metadata:, scope:, limit:, kinds: nil)
59
+ store.search(
60
+ embedding: embedding,
61
+ embedding_metadata: embedding_metadata,
62
+ scope: scope,
63
+ limit: limit,
64
+ kinds: kinds
65
+ )
66
+ rescue ArgumentError => error
67
+ raise unless unknown_embedding_metadata_keyword?(error)
68
+
69
+ store.search(embedding: embedding, scope: scope, limit: limit, kinds: kinds)
70
+ end
71
+
72
+ def validate_query!(embedding, embedding_metadata)
73
+ metadata = stringify_keys(embedding_metadata || {})
74
+ return if metadata.empty?
75
+
76
+ expected = metadata["dimensions"]
77
+ return unless expected && embedding.respond_to?(:length) && embedding.length != expected.to_i
78
+
79
+ raise Engram::Error,
80
+ "embedding dimension mismatch: query vector has #{embedding.length} dimensions, metadata declares #{expected}"
81
+ end
82
+
83
+ def validate_record!(record, query_embedding, query_metadata)
84
+ if query_embedding.respond_to?(:length) && record.embedding.respond_to?(:length) &&
85
+ query_embedding.length != record.embedding.length
86
+ raise Engram::Error,
87
+ "embedding dimension mismatch: query vector has #{query_embedding.length} dimensions, " \
88
+ "record #{record.id.inspect} has #{record.embedding.length}"
89
+ end
90
+
91
+ stored = extract(record.metadata)
92
+ return if stored.empty?
93
+
94
+ stored_dimensions = stored["dimensions"]
95
+ if stored_dimensions && record.embedding.respond_to?(:length) && record.embedding.length != stored_dimensions.to_i
96
+ raise Engram::Error,
97
+ "embedding metadata mismatch: record #{record.id.inspect} has #{record.embedding.length} dimensions, " \
98
+ "metadata declares #{stored_dimensions}"
99
+ end
100
+
101
+ query_metadata = stringify_keys(query_metadata || {})
102
+ return if query_metadata.empty?
103
+
104
+ conflicting_key = %w[adapter provider model dimensions fingerprint].find do |key|
105
+ stored.key?(key) && query_metadata.key?(key) && stored[key].to_s != query_metadata[key].to_s
106
+ end
107
+ return unless conflicting_key
108
+
109
+ raise Engram::Error,
110
+ "embedding metadata mismatch for record #{record.id.inspect}: #{conflicting_key} " \
111
+ "#{stored[conflicting_key].inspect} does not match query #{query_metadata[conflicting_key].inspect}"
112
+ end
113
+
114
+ def fingerprint(metadata)
115
+ relevant = stringify_keys(metadata).values_at("adapter", "provider", "model", "dimensions")
116
+ Digest::SHA256.hexdigest(relevant.map { |value| value.nil? ? "" : value.to_s }.join("\0"))
117
+ end
118
+
119
+ def stringify_keys(value)
120
+ case value
121
+ when Hash
122
+ value.each_with_object({}) do |(key, nested), out|
123
+ out[key.to_s] = stringify_keys(nested)
124
+ end
125
+ else
126
+ value
127
+ end
128
+ end
129
+
130
+ def unknown_embedding_metadata_keyword?(error)
131
+ error.message.include?("unknown keyword: :embedding_metadata") ||
132
+ error.message.include?("unknown keyword: \"embedding_metadata\"")
133
+ end
134
+ end
135
+ end
@@ -58,14 +58,15 @@ module Engram
58
58
  next if content.empty?
59
59
  next if (fact["confidence"] || 1.0).to_f < @min_confidence
60
60
 
61
- Engram::Record.new(
61
+ embedding = @embedder.embed(content)
62
+ Engram::EmbeddingMetadata.attach(Engram::Record.new(
62
63
  content: content,
63
64
  scope: scope,
64
65
  kind: fact["kind"] || "fact",
65
66
  importance: (fact["importance"] || 1.0).to_f,
66
67
  metadata: {confidence: (fact["confidence"] || 1.0).to_f},
67
- embedding: @embedder.embed(content)
68
- )
68
+ embedding: embedding
69
+ ), embedder: @embedder)
69
70
  end
70
71
  end
71
72
 
data/lib/engram/memory.rb CHANGED
@@ -16,10 +16,11 @@ module Engram
16
16
  # persistence policy rejects the record.
17
17
  def add(content, kind: :fact, importance: 1.0, metadata: {})
18
18
  Engram::Instrumentation.instrument("add", Engram::Instrumentation.payload(scope: scope, store: @store, kind: kind)) do
19
+ embedding = @embedder.embed(content)
19
20
  record = Record.new(
20
21
  content: content,
21
22
  scope: scope,
22
- embedding: @embedder.embed(content),
23
+ embedding: embedding,
23
24
  kind: kind,
24
25
  importance: importance,
25
26
  metadata: metadata
@@ -85,6 +86,17 @@ module Engram
85
86
  @store.all(scope: scope)
86
87
  end
87
88
 
89
+ # Recompute embeddings (and embedding metadata) for memories in the scope.
90
+ # When `stale_only` is true, only records whose current metadata does not match the
91
+ # active embedder are rebuilt.
92
+ def rebuild_embeddings(stale_only: true, batch_size: 100)
93
+ UseCases::RebuildEmbeddings.new(store: @store, embedder: @embedder).call(
94
+ scope: scope,
95
+ stale_only: stale_only,
96
+ batch_size: batch_size
97
+ )
98
+ end
99
+
88
100
  # Prune stale memories. `older_than` is a duration in seconds; `min_importance` keeps
89
101
  # memories at or above that importance even when old. Returns the forgotten records.
90
102
  def forget_stale(older_than:, min_importance: Float::INFINITY)
@@ -11,14 +11,17 @@ module Engram
11
11
  @persistence_policy = persistence_policy
12
12
  end
13
13
 
14
- def add(record)
14
+ def add(record, scope: record.scope)
15
15
  record = prepare(record)
16
+ if record && record.scope != scope
17
+ raise Engram::Error, "cannot move memory across scopes"
18
+ end
16
19
  @store.add(record) if record
17
20
  end
18
21
 
19
- def update(id:, record:)
22
+ def update(scope:, id:, record:)
20
23
  record = prepare(record)
21
- @store.update(id: id, record: record) if record
24
+ @store.update(scope: scope, id: id, record: record) if record
22
25
  end
23
26
 
24
27
  private
@@ -27,8 +30,10 @@ module Engram
27
30
  original_content = record.content
28
31
  record = @before_persist.call(record) if @before_persist
29
32
  record = @persistence_policy.call(record) if record && @persistence_policy
30
- record = record.with(embedding: @embedder.embed(record.content)) if record && record.content != original_content
31
- record
33
+ if record && record.content != original_content
34
+ record = record.with(embedding: @embedder.embed(record.content))
35
+ end
36
+ record ? Engram::EmbeddingMetadata.attach(record, embedder: @embedder) : nil
32
37
  end
33
38
  end
34
39
  end
@@ -13,28 +13,35 @@ module Engram
13
13
  # Return up to `limit` Records in `scope` nearest to `embedding`,
14
14
  # ordered most-relevant first. When `kinds` is provided, only records with
15
15
  # those canonical memory kinds are eligible.
16
- def search(embedding:, scope:, limit:, kinds: nil)
16
+ def search(embedding:, scope:, limit:, kinds: nil, embedding_metadata: nil)
17
17
  raise NotImplementedError, "#{self.class} must implement #search"
18
18
  end
19
19
 
20
20
  # All Records for a scope (mostly for inspection/tests).
21
- def all(scope:)
21
+ # Supports optional `limit` and `offset` for batching large sweeps.
22
+ # Returned records are sorted in stable `id` order when batching is used.
23
+ # Use `after_id` for keyset pagination.
24
+ def all(scope:, limit: nil, offset: 0, after_id: nil)
22
25
  raise NotImplementedError, "#{self.class} must implement #all"
23
26
  end
24
27
 
25
28
  # Replace the content/embedding of an existing memory. Used by consolidation
26
- # (UPDATE). Returns the updated Record.
27
- def update(id:, record:)
29
+ # (UPDATE). Returns the updated Record. Raises Engram::Error when the scoped
30
+ # record does not exist or the replacement would move it to another scope.
31
+ def update(scope:, id:, record:)
28
32
  raise NotImplementedError, "#{self.class} must implement #update"
29
33
  end
30
34
 
31
- # Remove a memory by id. Used by consolidation (FORGET).
32
- def delete(id:)
35
+ # Remove a memory by id. Used by consolidation (FORGET). Returns the number
36
+ # of affected rows: 1 when deleted, 0 when the scoped record does not exist.
37
+ def delete(scope:, id:)
33
38
  raise NotImplementedError, "#{self.class} must implement #delete"
34
39
  end
35
40
 
36
41
  # Update the last-accessed timestamp of a memory. Used by recency-aware recall.
37
- def touch(id:, at: Time.now)
42
+ # Returns the number of affected rows: 1 when touched, 0 when the scoped record
43
+ # does not exist.
44
+ def touch(scope:, id:, at: Time.now)
38
45
  raise NotImplementedError, "#{self.class} must implement #touch"
39
46
  end
40
47
  end
@@ -2,18 +2,26 @@
2
2
 
3
3
  module Engram
4
4
  module Ports
5
- # Contract for remembering which turns have already been observed, so observation is
6
- # idempotent across retries and accidental double-calls.
5
+ # Scoped lifecycle for observation idempotency. Implementations suppress concurrent claims
6
+ # while a lease is live. Lease expiry may permit overlap; this contract does not provide
7
+ # token ownership, fencing, or exactly-once execution.
8
+ # #claim returns a truthy opaque token when the caller may proceed, or nil when suppressed.
7
9
  # Implementations: Adapters::InMemoryProcessedTurns, Rails::CacheProcessedTurns.
8
10
  module ProcessedTurns
9
- # Has this idempotency key already been processed?
10
- def seen?(key)
11
- raise NotImplementedError, "#{self.class} must implement #seen?"
11
+ def claim(scope:, key:)
12
+ raise NotImplementedError, "#{self.class} must implement #claim"
12
13
  end
13
14
 
14
- # Mark this idempotency key as processed.
15
- def record(key)
16
- raise NotImplementedError, "#{self.class} must implement #record"
15
+ def complete(scope:, key:, claim:)
16
+ raise NotImplementedError, "#{self.class} must implement #complete"
17
+ end
18
+
19
+ def release(scope:, key:, claim:)
20
+ raise NotImplementedError, "#{self.class} must implement #release"
21
+ end
22
+
23
+ def completed?(scope:, key:)
24
+ raise NotImplementedError, "#{self.class} must implement #completed?"
17
25
  end
18
26
  end
19
27
  end
@@ -0,0 +1,257 @@
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
+
15
+ class Span
16
+ OFFSET_UNIT = "unicode_codepoint"
17
+
18
+ attr_reader :start_offset, :end_offset, :offset_unit
19
+
20
+ def initialize(start_offset:, end_offset:, offset_unit: OFFSET_UNIT)
21
+ unless start_offset.is_a?(Integer) && end_offset.is_a?(Integer) &&
22
+ start_offset >= 0 && end_offset > start_offset
23
+ raise ArgumentError, "span offsets must be non-negative integers with end_offset > start_offset"
24
+ end
25
+ raise ArgumentError, "unsupported offset unit" unless offset_unit.to_s == OFFSET_UNIT
26
+
27
+ @start_offset = start_offset
28
+ @end_offset = end_offset
29
+ @offset_unit = OFFSET_UNIT
30
+ freeze
31
+ end
32
+
33
+ def end_exclusive? = true
34
+
35
+ def ==(other)
36
+ other.is_a?(self.class) && to_h == other.to_h
37
+ end
38
+ alias_method :eql?, :==
39
+
40
+ def hash = to_h.hash
41
+
42
+ def to_h
43
+ {"start_offset" => start_offset, "end_offset" => end_offset, "offset_unit" => offset_unit}
44
+ end
45
+ end
46
+
47
+ class Source
48
+ attr_reader :source_id, :source_type, :message_index, :role, :spans, :alignment
49
+
50
+ def initialize(source_id:, source_type:, message_index:, role:, spans:, alignment:)
51
+ raise ArgumentError, "source_id is required" if source_id.to_s.strip.empty?
52
+ raise ArgumentError, "source_type is required" if source_type.to_s.strip.empty?
53
+ unless message_index.is_a?(Integer) && !message_index.negative?
54
+ raise ArgumentError, "message_index must be a non-negative integer"
55
+ end
56
+ raise ArgumentError, "role is required" if role.to_s.strip.empty?
57
+
58
+ unless alignment.is_a?(String) || alignment.is_a?(Symbol)
59
+ raise ArgumentError, "unknown alignment #{alignment.inspect}"
60
+ end
61
+ normalized_alignment = alignment.to_sym
62
+ raise ArgumentError, "unknown alignment #{alignment.inspect}" unless ALIGNMENTS.include?(normalized_alignment)
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
+ raise ArgumentError, "spans must be an array" unless spans.is_a?(Array)
69
+ raise ArgumentError, "spans must contain at least one span" if spans.empty?
70
+ @spans = spans.map do |span|
71
+ raise ArgumentError, "spans must contain Provenance::Span values" unless span.is_a?(Span)
72
+ span
73
+ end.freeze
74
+ @alignment = normalized_alignment
75
+ freeze
76
+ end
77
+
78
+ def ==(other)
79
+ other.is_a?(self.class) && to_h == other.to_h
80
+ end
81
+ alias_method :eql?, :==
82
+
83
+ def hash = to_h.hash
84
+
85
+ def to_h
86
+ data = {
87
+ "source_id" => source_id,
88
+ "source_type" => source_type,
89
+ "spans" => spans.map(&:to_h),
90
+ "alignment" => alignment.to_s
91
+ }
92
+ data["message_index"] = message_index
93
+ data["role"] = role
94
+ data
95
+ end
96
+ end
97
+
98
+ class Extractor
99
+ attr_reader :name, :provider, :model
100
+
101
+ def initialize(name:, model:, provider: nil)
102
+ raise ArgumentError, "extractor name is required" if name.to_s.strip.empty?
103
+ raise ArgumentError, "extractor model is required" if model.to_s.strip.empty?
104
+
105
+ @name = name.to_s.dup.freeze
106
+ @provider = provider&.to_s&.dup&.freeze
107
+ @model = model.to_s.dup.freeze
108
+ freeze
109
+ end
110
+
111
+ def ==(other)
112
+ other.is_a?(self.class) && to_h == other.to_h
113
+ end
114
+ alias_method :eql?, :==
115
+
116
+ def hash = to_h.hash
117
+
118
+ def to_h
119
+ {"name" => name, "model" => model}.tap do |data|
120
+ data["provider"] = provider if provider
121
+ end
122
+ end
123
+ end
124
+
125
+ attr_reader :sources, :extractor, :confidence
126
+
127
+ def initialize(sources:, extractor:, confidence:)
128
+ raise ArgumentError, "sources must be an array" unless sources.is_a?(Array)
129
+ raise ArgumentError, "sources must contain at least one source" if sources.empty?
130
+ @sources = sources.map do |source|
131
+ raise ArgumentError, "sources must contain Provenance::Source values" unless source.is_a?(Source)
132
+ source
133
+ end.freeze
134
+ raise ArgumentError, "extractor must be a Provenance::Extractor" unless extractor.is_a?(Extractor)
135
+ valid_confidence = (confidence.is_a?(Integer) || confidence.is_a?(Float)) && confidence.between?(0, 1)
136
+ unless valid_confidence
137
+ raise ArgumentError, "confidence must be an Integer or Float between 0 and 1"
138
+ end
139
+
140
+ @extractor = extractor
141
+ @confidence = confidence
142
+ freeze
143
+ end
144
+
145
+ def ==(other)
146
+ other.is_a?(self.class) && to_h == other.to_h
147
+ end
148
+ alias_method :eql?, :==
149
+
150
+ def hash = to_h.hash
151
+
152
+ def to_h
153
+ {
154
+ "version" => SCHEMA_VERSION,
155
+ "sources" => sources.map(&:to_h),
156
+ "extractor" => extractor.to_h,
157
+ "confidence" => confidence
158
+ }
159
+ end
160
+
161
+ class << self
162
+ def attach(metadata, provenance)
163
+ raise ArgumentError, "provenance must be a Provenance value" unless provenance.is_a?(self)
164
+
165
+ Engram::ReservedMetadata.attach(metadata, METADATA_KEY, provenance.to_h)
166
+ end
167
+
168
+ def extract(metadata)
169
+ metadata = deep_stringify(metadata || {})
170
+ return nil unless metadata.is_a?(Hash)
171
+
172
+ reserved = metadata[RESERVED_KEY]
173
+ return nil unless reserved.is_a?(Hash)
174
+
175
+ data = reserved[METADATA_KEY]
176
+ return nil unless data.is_a?(Hash) && data["version"] == SCHEMA_VERSION
177
+
178
+ from_h(data)
179
+ end
180
+
181
+ private
182
+
183
+ def from_h(data)
184
+ sources = provenance_array(data["sources"], "_engram.provenance.sources")
185
+ extractor_data = provenance_hash(data["extractor"], "_engram.provenance.extractor")
186
+
187
+ parsed_sources = sources.each_with_index.map do |source, source_index|
188
+ source_path = "_engram.provenance.sources[#{source_index}]"
189
+ source = provenance_hash(source, source_path)
190
+ spans = provenance_array(source["spans"], "#{source_path}.spans")
191
+ parsed_spans = spans.each_with_index.map do |span, span_index|
192
+ span_path = "#{source_path}.spans[#{span_index}]"
193
+ span = provenance_hash(span, span_path)
194
+ build_provenance_value(span_path) do
195
+ Span.new(
196
+ start_offset: span["start_offset"],
197
+ end_offset: span["end_offset"],
198
+ offset_unit: span["offset_unit"]
199
+ )
200
+ end
201
+ end
202
+
203
+ build_provenance_value(source_path) do
204
+ Source.new(
205
+ source_id: source["source_id"],
206
+ source_type: source["source_type"],
207
+ message_index: source["message_index"],
208
+ role: source["role"],
209
+ spans: parsed_spans,
210
+ alignment: source["alignment"]
211
+ )
212
+ end
213
+ end
214
+
215
+ extractor = build_provenance_value("_engram.provenance.extractor") do
216
+ Extractor.new(**symbolize_extractor(extractor_data))
217
+ end
218
+ build_provenance_value("_engram.provenance") do
219
+ new(sources: parsed_sources, extractor: extractor, confidence: data["confidence"])
220
+ end
221
+ end
222
+
223
+ def provenance_hash(value, path)
224
+ return value if value.is_a?(Hash)
225
+
226
+ raise Engram::Error, "malformed provenance at #{path}: expected an object"
227
+ end
228
+
229
+ def provenance_array(value, path)
230
+ return value if value.is_a?(Array)
231
+
232
+ raise Engram::Error, "malformed provenance at #{path}: expected an array"
233
+ end
234
+
235
+ def build_provenance_value(path)
236
+ yield
237
+ rescue ArgumentError, TypeError, KeyError, NoMethodError => error
238
+ raise Engram::Error, "malformed provenance at #{path}: #{error.message}"
239
+ end
240
+
241
+ def symbolize_extractor(data)
242
+ {name: data["name"], provider: data["provider"], model: data["model"]}
243
+ end
244
+
245
+ def deep_stringify(value)
246
+ case value
247
+ when Hash
248
+ value.each_with_object({}) { |(key, nested), out| out[key.to_s] = deep_stringify(nested) }
249
+ when Array
250
+ value.map { |nested| deep_stringify(nested) }
251
+ else
252
+ value
253
+ end
254
+ end
255
+ end
256
+ end
257
+ 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
@@ -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