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,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
@@ -36,11 +36,19 @@ module Engram
36
36
  )
37
37
  Engram::Instrumentation.instrument("recall", payload) do
38
38
  embedding = @embedder.embed(query)
39
+ embedding_metadata = Engram::EmbeddingMetadata.for_embedder(@embedder, embedding: embedding)
39
40
  pool_limit = reranking? ? limit * @pool_factor : limit
40
- pool = @store.search(embedding: embedding, scope: scope, limit: pool_limit, kinds: kinds)
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
+ )
41
49
 
42
50
  results = (reranking? ? rerank(pool, embedding) : pool).first(limit)
43
- touch(results) if @touch
51
+ touch(results, scope) if @touch
44
52
  payload[:result_count] = results.size
45
53
  payload[:candidate_count] = pool.size
46
54
  results
@@ -72,8 +80,8 @@ module Engram
72
80
  0.5**(age / @recency_halflife)
73
81
  end
74
82
 
75
- def touch(records)
76
- 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 }
77
85
  end
78
86
  end
79
87
  end
@@ -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.4.0"
4
+ VERSION = "0.6.0"
5
5
  end
data/lib/engram.rb CHANGED
@@ -3,8 +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"
7
+ require_relative "engram/reserved_metadata"
8
+ require_relative "engram/embedding_metadata"
9
+ require_relative "engram/provenance"
6
10
  require_relative "engram/memory_kind"
7
11
  require_relative "engram/record"
12
+ require_relative "engram/internal/scope"
13
+ require_relative "engram/internal/candidate_integrity"
14
+ require_relative "engram/extraction"
8
15
  require_relative "engram/decision"
9
16
  require_relative "engram/turn_digest"
10
17
  require_relative "engram/persistence_policy"
@@ -24,6 +31,9 @@ require_relative "engram/use_cases/recall"
24
31
  require_relative "engram/use_cases/inject"
25
32
  require_relative "engram/use_cases/observe"
26
33
  require_relative "engram/use_cases/forget"
34
+ require_relative "engram/use_cases/rebuild_embeddings"
35
+ require_relative "engram/use_cases/source_impact"
36
+ require_relative "engram/use_cases/grounding_report"
27
37
 
28
38
  # Built-in adapters (pure Ruby, no external deps)
29
39
  require_relative "engram/adapters/in_memory_store"
@@ -51,6 +61,9 @@ require_relative "engram/integrations/ruby_llm"
51
61
  module Engram
52
62
  class Error < StandardError; end
53
63
 
64
+ # Raised when a live claim suppresses an observation that has not completed.
65
+ class ObservationInProgressError < Error; end
66
+
54
67
  class << self
55
68
  def config
56
69
  @config ||= Configuration.new
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.4.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-06-06 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
@@ -36,9 +36,14 @@ files:
36
36
  - lib/engram/consolidators/heuristic_consolidator.rb
37
37
  - lib/engram/consolidators/llm_consolidator.rb
38
38
  - lib/engram/decision.rb
39
+ - lib/engram/embedding_metadata.rb
40
+ - lib/engram/extraction.rb
39
41
  - lib/engram/extractors/llm_extractor.rb
40
42
  - lib/engram/instrumentation.rb
41
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
42
47
  - lib/engram/math.rb
43
48
  - lib/engram/memory.rb
44
49
  - lib/engram/memory_kind.rb
@@ -50,16 +55,22 @@ files:
50
55
  - lib/engram/ports/extractor.rb
51
56
  - lib/engram/ports/memory_store.rb
52
57
  - lib/engram/ports/processed_turns.rb
58
+ - lib/engram/provenance.rb
53
59
  - lib/engram/rails/cache_processed_turns.rb
54
60
  - lib/engram/rails/has_memory.rb
55
61
  - lib/engram/rails/observe_job.rb
62
+ - lib/engram/rails/tasks.rake
56
63
  - lib/engram/railtie.rb
57
64
  - lib/engram/record.rb
65
+ - lib/engram/reserved_metadata.rb
58
66
  - lib/engram/turn_digest.rb
59
67
  - lib/engram/use_cases/forget.rb
68
+ - lib/engram/use_cases/grounding_report.rb
60
69
  - lib/engram/use_cases/inject.rb
61
70
  - lib/engram/use_cases/observe.rb
71
+ - lib/engram/use_cases/rebuild_embeddings.rb
62
72
  - lib/engram/use_cases/recall.rb
73
+ - lib/engram/use_cases/source_impact.rb
63
74
  - lib/engram/version.rb
64
75
  - lib/generators/engram/install_generator.rb
65
76
  - lib/generators/engram/templates/create_engram_memories.rb.tt
@@ -89,7 +100,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
89
100
  - !ruby/object:Gem::Version
90
101
  version: '0'
91
102
  requirements: []
92
- rubygems_version: 3.5.11
103
+ rubygems_version: 3.5.22
93
104
  signing_key:
94
105
  specification_version: 4
95
106
  summary: Long-term memory for AI agents in Ruby — stored in your own Postgres.