engram 0.3.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.
Files changed (37) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +118 -0
  3. data/README.md +409 -39
  4. data/lib/engram/adapters/in_memory_processed_turns.rb +40 -8
  5. data/lib/engram/adapters/in_memory_store.rb +49 -11
  6. data/lib/engram/adapters/null_embedder.rb +9 -0
  7. data/lib/engram/adapters/pgvector_store.rb +67 -20
  8. data/lib/engram/adapters/ruby_llm_embedder.rb +45 -4
  9. data/lib/engram/configuration.rb +5 -1
  10. data/lib/engram/consolidators/heuristic_consolidator.rb +7 -1
  11. data/lib/engram/consolidators/llm_consolidator.rb +44 -12
  12. data/lib/engram/embedding_metadata.rb +135 -0
  13. data/lib/engram/extractors/llm_extractor.rb +16 -6
  14. data/lib/engram/instrumentation.rb +57 -0
  15. data/lib/engram/memory.rb +42 -17
  16. data/lib/engram/memory_kind.rb +19 -0
  17. data/lib/engram/persistence.rb +39 -0
  18. data/lib/engram/persistence_policy.rb +45 -0
  19. data/lib/engram/ports/memory_store.rb +16 -8
  20. data/lib/engram/ports/processed_turns.rb +16 -8
  21. data/lib/engram/provenance.rb +257 -0
  22. data/lib/engram/rails/cache_processed_turns.rb +51 -10
  23. data/lib/engram/rails/observe_job.rb +5 -0
  24. data/lib/engram/rails/tasks.rake +26 -0
  25. data/lib/engram/railtie.rb +4 -0
  26. data/lib/engram/record.rb +8 -3
  27. data/lib/engram/reserved_metadata.rb +52 -0
  28. data/lib/engram/use_cases/forget.rb +6 -2
  29. data/lib/engram/use_cases/inject.rb +17 -3
  30. data/lib/engram/use_cases/observe.rb +83 -19
  31. data/lib/engram/use_cases/rebuild_embeddings.rb +189 -0
  32. data/lib/engram/use_cases/recall.rb +28 -9
  33. data/lib/engram/version.rb +1 -1
  34. data/lib/engram.rb +11 -0
  35. data/lib/generators/engram/install_generator.rb +10 -0
  36. data/lib/generators/engram/templates/create_engram_memories.rb.tt +10 -3
  37. metadata +14 -4
@@ -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
@@ -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
@@ -5,25 +5,30 @@ module Engram
5
5
  #
6
6
  # `id` is assigned by the store on persistence (nil until then); consolidation uses it
7
7
  # to target UPDATE/FORGET. `scope` namespaces memories to an owner (e.g. "user:42").
8
- # `kind` is a memory type (semantic / episodic / preference).
8
+ # `kind` is a memory type (fact / preference / instruction / episodic). The legacy
9
+ # `semantic` kind is normalized to `fact` for compatibility with pre-1.0 records.
9
10
  class Record
10
11
  attr_accessor :id, :last_accessed_at
11
12
  attr_reader :content, :embedding, :scope, :kind, :importance, :metadata,
12
13
  :created_at
13
14
 
14
- def initialize(content:, scope:, id: nil, embedding: nil, kind: :semantic,
15
+ def initialize(content:, scope:, id: nil, embedding: nil, kind: :fact,
15
16
  importance: 1.0, metadata: {}, created_at: nil, last_accessed_at: nil)
16
17
  @id = id
17
18
  @content = content
18
19
  @scope = scope
19
20
  @embedding = embedding
20
- @kind = kind
21
+ @kind = MemoryKind.normalize(kind)
21
22
  @importance = importance
22
23
  @metadata = metadata
23
24
  @created_at = created_at || Time.now
24
25
  @last_accessed_at = last_accessed_at
25
26
  end
26
27
 
28
+ def with(**attributes)
29
+ self.class.new(**to_h.merge(attributes))
30
+ end
31
+
27
32
  def to_h
28
33
  {
29
34
  id: id, content: content, scope: scope, embedding: embedding, kind: kind,
@@ -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
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "cgi"
4
+
3
5
  module Engram
4
6
  module UseCases
5
7
  # Render recalled memories into a prompt as a clearly delimited block.
@@ -12,10 +14,22 @@ module Engram
12
14
 
13
15
  # Returns a new prompt string. If there are no memories, the prompt is unchanged.
14
16
  def call(prompt:, memories:)
15
- return prompt if memories.nil? || memories.empty?
17
+ payload = {memory_count: memories&.size.to_i}
18
+ Engram::Instrumentation.instrument("inject", payload) do
19
+ next prompt if memories.nil? || memories.empty?
20
+
21
+ block = memories.map { |memory| render_memory(memory) }.join("\n")
22
+ "#{prompt}\n\n#{@header}:\n<engram-memories>\n#{block}\n</engram-memories>"
23
+ end
24
+ end
25
+
26
+ private
27
+
28
+ def render_memory(memory)
29
+ kind = CGI.escapeHTML((memory.kind || :fact).to_s)
30
+ content = CGI.escapeHTML(memory.content.to_s)
16
31
 
17
- block = memories.map { |m| "- #{m.content}" }.join("\n")
18
- "#{prompt}\n\n#{@header}:\n#{block}"
32
+ %(<engram-memory kind="#{kind}">#{content}</engram-memory>)
19
33
  end
20
34
  end
21
35
  end
@@ -9,51 +9,115 @@ module Engram
9
9
  # When a ProcessedTurns store and an idempotency_key are provided, a turn that was
10
10
  # already processed is skipped (no extraction, no duplicate memories).
11
11
  class Observe
12
- def initialize(store:, extractor:, consolidator:, processed_turns: nil)
12
+ def initialize(store:, extractor:, consolidator:, processed_turns: nil, embedder: Engram.config.embedder)
13
13
  @store = store
14
14
  @extractor = extractor
15
15
  @consolidator = consolidator
16
16
  @processed_turns = processed_turns
17
+ @embedder = embedder
17
18
  end
18
19
 
19
20
  # Returns the Array<Decision> that were applied (empty if skipped or nothing found).
20
21
  def call(messages:, scope:, idempotency_key: nil)
21
- return [] if already_processed?(idempotency_key)
22
+ payload = Engram::Instrumentation.payload(
23
+ scope: scope,
24
+ store: @store,
25
+ message_count: messages.size,
26
+ idempotency_key_present: !idempotency_key.nil?
27
+ )
28
+ Engram::Instrumentation.instrument("observe", payload) do
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
22
36
 
23
- candidates = @extractor.extract(messages: messages, scope: scope)
24
- if candidates.empty?
25
- mark_processed(idempotency_key)
26
- return []
27
- end
37
+ payload[:skipped] = true
38
+ payload[:candidate_count] = 0
39
+ payload[:decision_count] = 0
40
+ next []
41
+ end
42
+
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
28
51
 
29
- decisions = @consolidator.reconcile_all(candidates: candidates, scope: scope)
30
- decisions.each { |decision| apply(decision) }
31
- mark_processed(idempotency_key)
32
- decisions
52
+ decisions = consolidate(candidates: candidates, scope: scope)
53
+ applied_decisions = decisions.filter_map { |decision| apply(decision, scope) }
54
+ payload[:decision_count] = applied_decisions.size
55
+ payload[:decision_actions] = applied_decisions.map { |decision| decision.action.to_s }
56
+ complete_claim(scope, idempotency_key, claim)
57
+ applied_decisions
58
+ rescue
59
+ release_claim(scope, idempotency_key, claim)
60
+ raise
61
+ end
62
+ end
33
63
  end
34
64
 
35
65
  private
36
66
 
37
- def already_processed?(key)
38
- !!(key && @processed_turns&.seen?(key))
67
+ def acquire_claim(scope, key)
68
+ @processed_turns.claim(scope: scope, key: key) if key && @processed_turns
69
+ end
70
+
71
+ def complete_claim(scope, key, claim)
72
+ @processed_turns.complete(scope: scope, key: key, claim: claim) if claim
73
+ end
74
+
75
+ def release_claim(scope, key, claim)
76
+ @processed_turns.release(scope: scope, key: key, claim: claim) if claim
39
77
  end
40
78
 
41
- def mark_processed(key)
42
- @processed_turns.record(key) if key && @processed_turns
79
+ def extract(messages:, scope:)
80
+ payload = Engram::Instrumentation.payload(scope: scope, store: @store, message_count: messages.size)
81
+ Engram::Instrumentation.instrument("extract", payload) do
82
+ candidates = @extractor.extract(messages: messages, scope: scope)
83
+ payload[:candidate_count] = candidates.size
84
+ candidates
85
+ end
86
+ end
87
+
88
+ def consolidate(candidates:, scope:)
89
+ payload = Engram::Instrumentation.payload(scope: scope, store: @store, candidate_count: candidates.size)
90
+ Engram::Instrumentation.instrument("consolidate", payload) do
91
+ decisions = @consolidator.reconcile_all(candidates: candidates, scope: scope)
92
+ payload[:decision_count] = decisions.size
93
+ payload[:decision_actions] = decisions.map { |decision| decision.action.to_s }
94
+ decisions
95
+ end
43
96
  end
44
97
 
45
- def apply(decision)
98
+ def apply(decision, scope)
46
99
  case decision.action
47
100
  when :add
48
- @store.add(decision.candidate)
101
+ raise Engram::Error, "cannot move memory across scopes" unless decision.candidate.scope == scope
102
+
103
+ decision if persistence.add(decision.candidate, scope: scope)
49
104
  when :update
50
- @store.update(id: decision.target_id, record: decision.candidate) if decision.target_id
105
+ if decision.target_id && persistence.update(scope: scope, id: decision.target_id, record: decision.candidate)
106
+ decision
107
+ end
51
108
  when :forget
52
- @store.delete(id: decision.target_id) if decision.target_id
109
+ if decision.target_id
110
+ deleted = @store.delete(scope: scope, id: decision.target_id)
111
+ decision if deleted && deleted != 0
112
+ end
53
113
  when :noop
54
114
  nil
55
115
  end
56
116
  end
117
+
118
+ def persistence
119
+ @persistence ||= Persistence.new(store: @store, embedder: @embedder)
120
+ end
57
121
  end
58
122
  end
59
123
  end
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Engram
4
+ module UseCases
5
+ # Rebuild stored embeddings when the active embedder configuration changes.
6
+ # Returns counts useful for logging/observability:
7
+ # - :processed: total rows examined in scope
8
+ # - :updated: rows re-embedded and persisted
9
+ # - :skipped: rows skipped because they were unchanged or incomplete
10
+ # - :failed: count of rows that failed during rebuild
11
+ # - :failed_ids: record IDs that failed during rebuild
12
+ # - :failed_errors: error class/message keyed by failed record ID
13
+ class RebuildEmbeddings
14
+ def initialize(store:, embedder:)
15
+ @store = store
16
+ @embedder = embedder
17
+ end
18
+
19
+ def call(scope:, stale_only: true, batch_size: 100)
20
+ batch_size = Integer(batch_size)
21
+ raise ArgumentError, "batch_size must be greater than 0" unless batch_size.positive?
22
+
23
+ counts = {
24
+ processed: 0,
25
+ updated: 0,
26
+ skipped: 0,
27
+ failed: 0,
28
+ failed_ids: [],
29
+ failed_errors: {}
30
+ }
31
+
32
+ after_id = nil
33
+ legacy_index = 0
34
+ legacy_records = nil
35
+ processed_ids = Set.new
36
+ processed_nil_records = Set.new
37
+ loop do
38
+ batch_result = fetch_batch(scope:, batch_size:, after_id:, legacy_records:, legacy_index:)
39
+ legacy_records = batch_result[:legacy_records] if batch_result[:legacy_records]
40
+ batch = batch_result[:records]
41
+ if batch.empty?
42
+ break if legacy_records
43
+
44
+ # Reconcile with one stable snapshot: accepting keyset keywords does
45
+ # not guarantee that an adapter returns complete, ordered pages.
46
+ legacy_records = Array(@store.all(scope: scope))
47
+ legacy_index = 0
48
+ next
49
+ end
50
+
51
+ cursor_record = batch.reverse.find { |candidate| !candidate.id.nil? } unless legacy_records
52
+
53
+ if !legacy_records && fallback_before_processing?(batch:, after_id:)
54
+ legacy_records = Array(@store.all(scope: scope))
55
+ legacy_index = 0
56
+ batch = legacy_records.slice(legacy_index, batch_size) || []
57
+ break if batch.empty?
58
+ end
59
+
60
+ batch.each do |record|
61
+ identity = record.id.nil? ? record.object_id : record.id
62
+ seen = record.id.nil? ? processed_nil_records : processed_ids
63
+ next unless seen.add?(identity)
64
+
65
+ counts[:processed] += 1
66
+
67
+ if record.id.nil?
68
+ counts[:skipped] += 1
69
+ next
70
+ end
71
+
72
+ if stale_only && !stale?(record)
73
+ counts[:skipped] += 1
74
+ next
75
+ end
76
+
77
+ begin
78
+ rebuilt = record.with(embedding: @embedder.embed(record.content))
79
+ rebuilt = EmbeddingMetadata.attach(rebuilt, embedder: @embedder)
80
+ updated = @store.update(scope: scope, id: record.id, record: rebuilt)
81
+ raise Engram::Error, "memory update was not applied" unless updated && updated != 0
82
+
83
+ counts[:updated] += 1
84
+ rescue => error
85
+ counts[:failed] += 1
86
+ record_id = record.id
87
+ counts[:failed_ids] << record_id
88
+ counts[:failed_errors][record_id] = {
89
+ class: error.class.name,
90
+ message: error.message
91
+ }
92
+ warn "Rebuild failed for record ##{record_id}: #{error.class}: #{error.message}"
93
+ end
94
+ end
95
+
96
+ if legacy_records
97
+ legacy_index += batch.length
98
+ next
99
+ end
100
+
101
+ if cursor_record.nil?
102
+ legacy_records = Array(@store.all(scope: scope))
103
+ legacy_index = 0
104
+ next
105
+ end
106
+
107
+ if fallback_to_legacy_batching?(batch:, cursor_record:)
108
+ legacy_records = Array(@store.all(scope: scope))
109
+ legacy_index = 0
110
+ next
111
+ end
112
+
113
+ after_id = cursor_record.id
114
+ end
115
+
116
+ {scope: scope, **counts}
117
+ end
118
+
119
+ private
120
+
121
+ def fetch_batch(scope:, batch_size:, after_id:, legacy_records:, legacy_index:)
122
+ if legacy_records
123
+ return {records: legacy_records.slice(legacy_index, batch_size) || [], legacy_records:}
124
+ end
125
+
126
+ records = modern_batch(scope:, batch_size:, after_id:)
127
+ return {records:} if records
128
+
129
+ legacy_records = Array(@store.all(scope: scope))
130
+ {records: legacy_records.slice(legacy_index, batch_size) || [], legacy_records:}
131
+ end
132
+
133
+ def modern_batch(scope:, batch_size:, after_id:)
134
+ return unless batched_all_supported?
135
+
136
+ @store.all(scope: scope, limit: batch_size, after_id: after_id)
137
+ end
138
+
139
+ def batched_all_supported?
140
+ parameters = @store.method(:all).parameters
141
+ keyword_names = parameters.filter_map do |kind, name|
142
+ name if [:keyreq, :key].include?(kind)
143
+ end
144
+
145
+ keyword_names.include?(:limit) && keyword_names.include?(:after_id)
146
+ end
147
+
148
+ def fallback_to_legacy_batching?(batch:, cursor_record:)
149
+ cursor_record != batch.last
150
+ end
151
+
152
+ def fallback_before_processing?(batch:, after_id:)
153
+ ids = batch.filter_map(&:id)
154
+ return true if ids.uniq.length != ids.length
155
+ return false if after_id.nil?
156
+ return true if batch.any? { |record| record.id.nil? }
157
+
158
+ batch.any? { |record| record.id && record.id <= after_id }
159
+ end
160
+
161
+ def stale?(record)
162
+ stored = EmbeddingMetadata.extract(record.metadata)
163
+ return true if stored.empty?
164
+
165
+ expected = expected_metadata(record)
166
+ return true unless expected
167
+
168
+ required = %w[adapter provider model dimensions fingerprint]
169
+ return true if required.any? { |key| stored[key] != expected[key] }
170
+
171
+ return true if stored["dimensions"] &&
172
+ record.embedding.respond_to?(:length) &&
173
+ record.embedding.length != stored["dimensions"].to_i
174
+
175
+ false
176
+ end
177
+
178
+ # What the active embedder would produce for a rebuilt row. Declared dimensions take
179
+ # precedence so dimension-only configuration drift marks rows stale; fall back to the
180
+ # record's current vector length for embedders that do not declare dimensions.
181
+ def expected_metadata(record)
182
+ declared = EmbeddingMetadata.for_embedder(@embedder)
183
+ return declared if declared.nil? || declared["dimensions"]
184
+
185
+ EmbeddingMetadata.for_embedder(@embedder, embedding: record.embedding)
186
+ end
187
+ end
188
+ end
189
+ end
@@ -24,16 +24,35 @@ module Engram
24
24
  end
25
25
 
26
26
  # Returns Array<Record>, most relevant first.
27
- def call(query, scope:, limit: Engram.config.default_limit)
27
+ def call(query, scope:, limit: Engram.config.default_limit, kinds: nil)
28
28
  raise ArgumentError, "query must be a non-empty string" if query.to_s.strip.empty?
29
29
 
30
- embedding = @embedder.embed(query)
31
- pool_limit = reranking? ? limit * @pool_factor : limit
32
- pool = @store.search(embedding: embedding, scope: scope, limit: pool_limit)
30
+ payload = Engram::Instrumentation.payload(
31
+ scope: scope,
32
+ store: @store,
33
+ limit: limit,
34
+ kinds: Array(kinds).map(&:to_s),
35
+ reranking: reranking?
36
+ )
37
+ Engram::Instrumentation.instrument("recall", payload) do
38
+ embedding = @embedder.embed(query)
39
+ embedding_metadata = Engram::EmbeddingMetadata.for_embedder(@embedder, embedding: embedding)
40
+ pool_limit = reranking? ? limit * @pool_factor : limit
41
+ pool = Engram::EmbeddingMetadata.search(
42
+ @store,
43
+ embedding: embedding,
44
+ embedding_metadata: embedding_metadata,
45
+ scope: scope,
46
+ limit: pool_limit,
47
+ kinds: kinds
48
+ )
33
49
 
34
- results = (reranking? ? rerank(pool, embedding) : pool).first(limit)
35
- touch(results) if @touch
36
- results
50
+ results = (reranking? ? rerank(pool, embedding) : pool).first(limit)
51
+ touch(results, scope) if @touch
52
+ payload[:result_count] = results.size
53
+ payload[:candidate_count] = pool.size
54
+ results
55
+ end
37
56
  end
38
57
 
39
58
  private
@@ -61,8 +80,8 @@ module Engram
61
80
  0.5**(age / @recency_halflife)
62
81
  end
63
82
 
64
- def touch(records)
65
- records.each { |record| @store.touch(id: record.id, at: Time.now) if record.id }
83
+ def touch(records, scope)
84
+ records.each { |record| @store.touch(scope: scope, id: record.id, at: Time.now) if record.id }
66
85
  end
67
86
  end
68
87
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Engram
4
- VERSION = "0.3.0"
4
+ VERSION = "0.5.0"
5
5
  end