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
@@ -28,7 +28,8 @@ module Engram
28
28
  to_record(row)
29
29
  end
30
30
 
31
- def search(embedding:, scope:, limit:, kinds: nil)
31
+ def search(embedding:, scope:, limit:, kinds: nil, embedding_metadata: nil)
32
+ Engram::EmbeddingMetadata.validate_query!(embedding, embedding_metadata)
32
33
  query = model.where(scope: scope)
33
34
  normalized_kinds = normalize_kinds(kinds)
34
35
  query = query.where(kind: normalized_kinds) if normalized_kinds
@@ -37,30 +38,56 @@ module Engram
37
38
  .nearest_neighbors(:embedding, embedding, distance: "cosine")
38
39
  .limit(limit)
39
40
  .map { |row| to_record(row) }
41
+ .tap { |records| validate_query_records!(records, embedding, embedding_metadata) }
40
42
  end
41
43
 
42
- def all(scope:)
43
- model.where(scope: scope).map { |row| to_record(row) }
44
+ def all(scope:, limit: nil, offset: 0, after_id: nil)
45
+ query = model.where(scope: scope).order(:id)
46
+ query = query.where("id > ?", after_id) if after_id
47
+ query = query.limit(limit) if limit
48
+ query = query.offset(offset) if offset > 0
49
+ query.map { |row| to_record(row) }
44
50
  end
45
51
 
46
- def update(id:, record:)
47
- row = model.find(id)
48
- row.update!(
49
- content: record.content,
50
- kind: record.kind.to_s,
51
- importance: record.importance,
52
- metadata: record.metadata,
53
- embedding: record.embedding
54
- )
55
- to_record(row)
52
+ def existing_ids(scope:, ids:)
53
+ persisted_ids = model.where(scope: scope, id: ids).pluck(:id)
54
+ id_type = model.type_for_attribute("id")
55
+ persisted_lookup = persisted_ids.each_with_object({}) { |id, lookup| lookup[id] = true }
56
+ matched_ids = {}
57
+
58
+ ids.select do |id|
59
+ cast_id = id_type.cast(id)
60
+ next false unless persisted_lookup[cast_id]
61
+ next false if matched_ids[cast_id]
62
+
63
+ matched_ids[cast_id] = true
64
+ end
65
+ end
66
+
67
+ def update(scope:, id:, record:)
68
+ raise Engram::Error, "cannot move memory across scopes" unless record.scope == scope
69
+
70
+ model.transaction do
71
+ row = model.lock.find_by!(id: id, scope: scope)
72
+ row.update!(
73
+ content: record.content,
74
+ kind: record.kind.to_s,
75
+ importance: record.importance,
76
+ metadata: record.metadata,
77
+ embedding: record.embedding
78
+ )
79
+ to_record(row)
80
+ end
81
+ rescue ActiveRecord::RecordNotFound
82
+ raise Engram::Error, "no memory with id #{id.inspect} in scope #{scope.inspect}"
56
83
  end
57
84
 
58
- def delete(id:)
59
- model.where(id: id).delete_all
85
+ def delete(scope:, id:)
86
+ model.where(id: id, scope: scope).delete_all
60
87
  end
61
88
 
62
- def touch(id:, at: Time.now)
63
- model.where(id: id).update_all(last_accessed_at: at)
89
+ def touch(scope:, id:, at: Time.now)
90
+ model.where(id: id, scope: scope).update_all(last_accessed_at: at)
64
91
  end
65
92
 
66
93
  private
@@ -69,6 +96,12 @@ module Engram
69
96
  raise Engram::Error, "memory scope cannot be nil" if scope.nil?
70
97
  end
71
98
 
99
+ def validate_query_records!(records, embedding, embedding_metadata)
100
+ records.each do |record|
101
+ Engram::EmbeddingMetadata.validate_record!(record, embedding, embedding_metadata)
102
+ end
103
+ end
104
+
72
105
  def model
73
106
  @model ||= resolve_default_model
74
107
  end
@@ -10,16 +10,32 @@ module Engram
10
10
  DEFAULT_MODEL = "text-embedding-3-small"
11
11
  DEFAULT_DIMENSIONS = 1536
12
12
 
13
- def initialize(model: DEFAULT_MODEL, dimensions: DEFAULT_DIMENSIONS)
13
+ def initialize(model: DEFAULT_MODEL, dimensions: nil)
14
+ if dimensions && (!dimensions.is_a?(Integer) || !dimensions.positive?)
15
+ raise ArgumentError, "dimensions must be a positive integer"
16
+ end
17
+
14
18
  @model = model
15
- @dimensions = dimensions
19
+ @requested_dimensions = dimensions
20
+ @dimensions = dimensions || DEFAULT_DIMENSIONS
16
21
  end
17
22
 
18
- attr_reader :dimensions
23
+ attr_reader :dimensions, :model
24
+
25
+ def embedding_metadata
26
+ Engram::EmbeddingMetadata.build(
27
+ adapter: self.class.name,
28
+ provider: "ruby_llm",
29
+ model: model,
30
+ dimensions: dimensions
31
+ )
32
+ end
19
33
 
20
34
  def embed(text)
21
35
  ensure_ruby_llm!
22
- RubyLLM.embed(text, model: @model).vectors
36
+ vectors = request_embedding(text)
37
+ validate_dimensions!(vectors)
38
+ vectors
23
39
  end
24
40
 
25
41
  private
@@ -30,6 +46,31 @@ module Engram
30
46
  raise Engram::Error,
31
47
  "RubyLLMEmbedder requires the `ruby_llm` gem. Add it to your Gemfile and configure it."
32
48
  end
49
+
50
+ # Older RubyLLM versions do not accept the dimensions keyword.
51
+ def request_embedding(text)
52
+ if @requested_dimensions && embed_accepts_dimensions?
53
+ RubyLLM.embed(text, model: @model, dimensions: @requested_dimensions).vectors
54
+ else
55
+ RubyLLM.embed(text, model: @model).vectors
56
+ end
57
+ end
58
+
59
+ def embed_accepts_dimensions?
60
+ RubyLLM.method(:embed).parameters.any? do |type, name|
61
+ type == :keyrest || ([:key, :keyreq].include?(type) && name == :dimensions)
62
+ end
63
+ end
64
+
65
+ def validate_dimensions!(vectors)
66
+ length = vectors.respond_to?(:length) ? vectors.length : nil
67
+ return if length == @dimensions
68
+
69
+ raise Engram::Error,
70
+ "embedding model #{@model.inspect} returned #{length ? "#{length}-dimension" : "non-vector"} output " \
71
+ "but the embedder is configured with dimensions: #{@dimensions}; align the dimensions option " \
72
+ "(and your vector column) with the model's output"
73
+ end
33
74
  end
34
75
  end
35
76
  end
@@ -15,7 +15,13 @@ module Engram
15
15
 
16
16
  def reconcile_all(candidates:, scope:)
17
17
  Array(candidates).map do |candidate|
18
- nearest = @store.search(embedding: candidate.embedding, scope: scope, limit: 1).first
18
+ nearest = Engram::EmbeddingMetadata.search(
19
+ @store,
20
+ embedding: candidate.embedding,
21
+ embedding_metadata: Engram::EmbeddingMetadata.extract(candidate.metadata),
22
+ scope: scope,
23
+ limit: 1
24
+ ).first
19
25
  similarity = nearest ? Engram::Math.cosine_similarity(candidate.embedding, nearest.embedding) : 0.0
20
26
 
21
27
  if nearest && similarity >= @similarity_threshold
@@ -56,23 +56,35 @@ module Engram
56
56
  candidates = Array(candidates)
57
57
  return [] if candidates.empty?
58
58
 
59
+ neighbors = neighbor_map(candidates, scope)
59
60
  result = @completion.complete(
60
61
  system: SYSTEM,
61
- user: JSON.generate(payload(candidates, scope)),
62
+ user: JSON.generate(payload(candidates, neighbors)),
62
63
  schema: SCHEMA
63
64
  )
64
- map_decisions(decisions(result), candidates)
65
+ map_decisions(decisions(result), candidates, neighbors)
65
66
  end
66
67
 
67
68
  private
68
69
 
69
- def payload(candidates, scope)
70
+ def neighbor_map(candidates, scope)
71
+ candidates.map do |candidate|
72
+ Engram::EmbeddingMetadata.search(
73
+ @store,
74
+ embedding: candidate.embedding,
75
+ embedding_metadata: Engram::EmbeddingMetadata.extract(candidate.metadata),
76
+ scope: scope,
77
+ limit: @neighbors
78
+ )
79
+ end
80
+ end
81
+
82
+ def payload(candidates, neighbors)
70
83
  items = candidates.each_with_index.map do |candidate, index|
71
- existing = @store.search(embedding: candidate.embedding, scope: scope, limit: @neighbors)
72
84
  {
73
85
  index: index,
74
86
  candidate: candidate.content,
75
- existing: existing.map { |r| {id: r.id, content: r.content} }
87
+ existing: neighbors[index].map { |r| {id: r.id, content: r.content} }
76
88
  }
77
89
  end
78
90
  {candidates: items}
@@ -81,19 +93,34 @@ module Engram
81
93
  def decisions(result)
82
94
  return [] unless result.is_a?(Hash)
83
95
 
84
- result["decisions"] || result[:decisions] || []
96
+ decisions = result["decisions"] || result[:decisions] || []
97
+ decisions.is_a?(Array) ? decisions : []
85
98
  end
86
99
 
87
- def map_decisions(raw, candidates)
100
+ def map_decisions(raw, candidates, neighbors)
101
+ seen = Set.new
88
102
  raw.filter_map do |decision|
103
+ next unless decision.is_a?(Hash)
104
+
89
105
  decision = decision.transform_keys(&:to_s)
90
106
  index = decision["index"]
91
- next unless index && candidates[index]
107
+ next unless index.is_a?(Integer) && index >= 0 && candidates[index]
108
+ next if seen.include?(index)
109
+
110
+ action = (decision["action"] || "noop").to_s
111
+ next unless Engram::Decision::ACTIONS.include?(action.to_sym)
112
+
113
+ target_id = decision["target_id"]
114
+ if %w[update forget].include?(action)
115
+ next if target_id.nil?
116
+ next unless neighbors[index].any? { |neighbor| neighbor.id == target_id }
117
+ end
92
118
 
119
+ seen.add(index)
93
120
  Engram::Decision.new(
94
- action: (decision["action"] || "noop").to_sym,
121
+ action: action.to_sym,
95
122
  candidate: candidates[index],
96
- target_id: decision["target_id"],
123
+ target_id: target_id,
97
124
  reason: decision["reason"]
98
125
  )
99
126
  end
@@ -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
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Engram
4
+ # An immutable extractor result that carries a candidate Record and its source provenance.
5
+ # Converting it creates a new record and leaves the caller-owned record and metadata untouched.
6
+ class Extraction
7
+ attr_reader :record, :provenance
8
+
9
+ def initialize(record:, provenance:)
10
+ is_record = Object.instance_method(:is_a?).bind_call(record, Engram::Record)
11
+ raise ArgumentError, "record must be an Engram::Record" unless is_record
12
+
13
+ is_provenance = Object.instance_method(:is_a?).bind_call(provenance, Engram::Provenance)
14
+ raise ArgumentError, "provenance must be an Engram::Provenance" unless is_provenance
15
+
16
+ @record = record
17
+ @provenance = provenance
18
+ freeze
19
+ end
20
+
21
+ def to_record
22
+ source = Engram::Internal::CandidateIntegrity.new.detach(record)
23
+ attributes = Engram::Record::STATE_READERS.to_h do |attribute|
24
+ [attribute, Engram::Record.instance_method(attribute).bind_call(source)]
25
+ end
26
+ attributes[:metadata] = Engram::Provenance.attach(attributes.fetch(:metadata), provenance)
27
+ Engram::Record.new(**attributes)
28
+ end
29
+ end
30
+ 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