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
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Engram
4
+ # Optional ActiveSupport::Notifications integration.
5
+ #
6
+ # Engram core remains dependency-free: when ActiveSupport is not loaded, instrumentation
7
+ # is a no-op around the supplied block.
8
+ module Instrumentation
9
+ module_function
10
+
11
+ def instrument(event, payload = {})
12
+ started_at = monotonic_time
13
+
14
+ unless notifications?
15
+ return yield if block_given?
16
+ return nil
17
+ end
18
+
19
+ ActiveSupport::Notifications.instrument("#{event}.engram", payload) do
20
+ yield if block_given?
21
+ ensure
22
+ payload[:duration_ms] = elapsed_ms(started_at)
23
+ end
24
+ end
25
+
26
+ def payload(scope: nil, store: nil, **attributes)
27
+ attributes = attributes.compact
28
+ attributes[:store_adapter] = adapter_name(store) if store
29
+ scope_identifier = scope_identifier(scope)
30
+ attributes[:scope_identifier] = scope_identifier if scope_identifier
31
+ attributes
32
+ end
33
+
34
+ def notifications?
35
+ defined?(ActiveSupport::Notifications) && ActiveSupport::Notifications.respond_to?(:instrument)
36
+ end
37
+
38
+ def adapter_name(adapter)
39
+ adapter.class.name || adapter.class.to_s
40
+ end
41
+
42
+ def scope_identifier(scope)
43
+ formatter = Engram.config.instrumentation_scope_identifier
44
+ return nil unless formatter
45
+
46
+ formatter.respond_to?(:call) ? formatter.call(scope) : scope.to_s
47
+ end
48
+
49
+ def elapsed_ms(started_at)
50
+ ((monotonic_time - started_at) * 1000).round(1)
51
+ end
52
+
53
+ def monotonic_time
54
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
55
+ end
56
+ end
57
+ end
data/lib/engram/memory.rb CHANGED
@@ -12,21 +12,25 @@ module Engram
12
12
  @embedder = embedder
13
13
  end
14
14
 
15
- # Persist a fact. (In v0.2 this is mostly done for you via extract/consolidate.)
16
- def add(content, kind: :semantic, importance: 1.0, metadata: {})
17
- record = Record.new(
18
- content: content,
19
- scope: scope,
20
- embedding: @embedder.embed(content),
21
- kind: kind,
22
- importance: importance,
23
- metadata: metadata
24
- )
25
- @store.add(record)
15
+ # Persist a memory record of the given kind. Returns nil when the configured
16
+ # persistence policy rejects the record.
17
+ def add(content, kind: :fact, importance: 1.0, metadata: {})
18
+ Engram::Instrumentation.instrument("add", Engram::Instrumentation.payload(scope: scope, store: @store, kind: kind)) do
19
+ embedding = @embedder.embed(content)
20
+ record = Record.new(
21
+ content: content,
22
+ scope: scope,
23
+ embedding: embedding,
24
+ kind: kind,
25
+ importance: importance,
26
+ metadata: metadata
27
+ )
28
+ persist(record)
29
+ end
26
30
  end
27
31
 
28
32
  # Return the most relevant memories for a query.
29
- def recall(query, limit: Engram.config.default_limit)
33
+ def recall(query, limit: Engram.config.default_limit, kinds: nil)
30
34
  UseCases::Recall.new(
31
35
  store: @store,
32
36
  embedder: @embedder,
@@ -34,12 +38,12 @@ module Engram
34
38
  recency_weight: Engram.config.recency_weight,
35
39
  recency_halflife: Engram.config.recency_halflife,
36
40
  touch: Engram.config.touch_on_recall
37
- ).call(query, scope: scope, limit: limit)
41
+ ).call(query, scope: scope, limit: limit, kinds: kinds)
38
42
  end
39
43
 
40
44
  # Recall, then inject into a prompt string.
41
- def inject_into(prompt, query:, limit: Engram.config.default_limit)
42
- memories = recall(query, limit: limit)
45
+ def inject_into(prompt, query:, limit: Engram.config.default_limit, kinds: nil)
46
+ memories = recall(query, limit: limit, kinds: kinds)
43
47
  UseCases::Inject.new.call(prompt: prompt, memories: memories)
44
48
  end
45
49
 
@@ -55,7 +59,8 @@ module Engram
55
59
  store: @store,
56
60
  extractor: build_extractor(completion),
57
61
  consolidator: build_consolidator(completion),
58
- processed_turns: Engram.config.processed_turns
62
+ processed_turns: Engram.config.processed_turns,
63
+ embedder: @embedder
59
64
  ).call(
60
65
  messages: messages,
61
66
  scope: scope,
@@ -69,13 +74,29 @@ module Engram
69
74
  raise Engram::Error, "observe_later needs ActiveJob (Rails). Use #observe outside Rails."
70
75
  end
71
76
 
72
- Engram::ObserveJob.perform_later(scope, messages)
77
+ Engram::Instrumentation.instrument(
78
+ "observe_later",
79
+ Engram::Instrumentation.payload(scope: scope, store: @store, message_count: messages.size)
80
+ ) do
81
+ Engram::ObserveJob.perform_later(scope, messages)
82
+ end
73
83
  end
74
84
 
75
85
  def all
76
86
  @store.all(scope: scope)
77
87
  end
78
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
+
79
100
  # Prune stale memories. `older_than` is a duration in seconds; `min_importance` keeps
80
101
  # memories at or above that importance even when old. Returns the forgotten records.
81
102
  def forget_stale(older_than:, min_importance: Float::INFINITY)
@@ -85,6 +106,10 @@ module Engram
85
106
 
86
107
  private
87
108
 
109
+ def persist(record)
110
+ Persistence.new(store: @store, embedder: @embedder).add(record)
111
+ end
112
+
88
113
  def build_extractor(completion)
89
114
  Extractors::LLMExtractor.new(
90
115
  completion: completion,
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Engram
4
+ # Canonical memory categories used by extraction, recall, and policy.
5
+ module MemoryKind
6
+ VALID = %i[fact preference instruction episodic].freeze
7
+ LEGACY_ALIASES = {semantic: :fact}.freeze
8
+
9
+ module_function
10
+
11
+ def normalize(kind)
12
+ normalized = kind.to_s.strip.downcase.to_sym
13
+ normalized = LEGACY_ALIASES.fetch(normalized, normalized)
14
+ return normalized if VALID.include?(normalized)
15
+
16
+ raise ArgumentError, "unknown memory kind #{kind.inspect}; expected one of #{VALID.join(", ")}"
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Engram
4
+ # Applies persistence hooks and policy consistently before writing records.
5
+ class Persistence
6
+ def initialize(store:, embedder:, before_persist: Engram.config.before_persist,
7
+ persistence_policy: Engram.config.persistence_policy)
8
+ @store = store
9
+ @embedder = embedder
10
+ @before_persist = before_persist
11
+ @persistence_policy = persistence_policy
12
+ end
13
+
14
+ def add(record, scope: record.scope)
15
+ record = prepare(record)
16
+ if record && record.scope != scope
17
+ raise Engram::Error, "cannot move memory across scopes"
18
+ end
19
+ @store.add(record) if record
20
+ end
21
+
22
+ def update(scope:, id:, record:)
23
+ record = prepare(record)
24
+ @store.update(scope: scope, id: id, record: record) if record
25
+ end
26
+
27
+ private
28
+
29
+ def prepare(record)
30
+ original_content = record.content
31
+ record = @before_persist.call(record) if @before_persist
32
+ record = @persistence_policy.call(record) if record && @persistence_policy
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
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Engram
4
+ # Default gate applied before memories are persisted. It keeps obvious secrets and
5
+ # transient task-progress updates out of durable memory, and can redact caller-supplied
6
+ # denylist patterns before storage.
7
+ class PersistencePolicy
8
+ SECRET_PATTERNS = [
9
+ /\b(?:api[_ -]?key|token|secret|password)\b\s*(?:is|=|:)\s+(?=\S*[0-9_-])\S{8,}/i,
10
+ /\bsk-[A-Za-z0-9_-]{6,}\b/,
11
+ /\b(?:ghp|github_pat)_[A-Za-z0-9_]{10,}\b/
12
+ ].freeze
13
+
14
+ TRANSIENT_PATTERNS = [
15
+ /\b(?:fixed|resolved|done|completed|finished)\b.*\b(?:today|now|this session)\b/i,
16
+ /\b(?:today|now|this session)\b.*\b(?:fixed|resolved|done|completed|finished)\b/i
17
+ ].freeze
18
+
19
+ def initialize(denylist_patterns: [])
20
+ @denylist_patterns = denylist_patterns
21
+ end
22
+
23
+ def call(record)
24
+ return nil if reject?(record.content)
25
+
26
+ redact(record)
27
+ end
28
+
29
+ private
30
+
31
+ def reject?(content)
32
+ SECRET_PATTERNS.any? { |pattern| content.match?(pattern) } ||
33
+ TRANSIENT_PATTERNS.any? { |pattern| content.match?(pattern) }
34
+ end
35
+
36
+ def redact(record)
37
+ redacted = @denylist_patterns.reduce(record.content) do |content, pattern|
38
+ content.gsub(pattern, "[REDACTED]")
39
+ end
40
+ return record if redacted == record.content
41
+
42
+ record.with(content: redacted)
43
+ end
44
+ end
45
+ end
@@ -11,29 +11,37 @@ module Engram
11
11
  end
12
12
 
13
13
  # Return up to `limit` Records in `scope` nearest to `embedding`,
14
- # ordered most-relevant first.
15
- def search(embedding:, scope:, limit:)
14
+ # ordered most-relevant first. When `kinds` is provided, only records with
15
+ # those canonical memory kinds are eligible.
16
+ def search(embedding:, scope:, limit:, kinds: nil, embedding_metadata: nil)
16
17
  raise NotImplementedError, "#{self.class} must implement #search"
17
18
  end
18
19
 
19
20
  # All Records for a scope (mostly for inspection/tests).
20
- 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)
21
25
  raise NotImplementedError, "#{self.class} must implement #all"
22
26
  end
23
27
 
24
28
  # Replace the content/embedding of an existing memory. Used by consolidation
25
- # (UPDATE). Returns the updated Record.
26
- 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:)
27
32
  raise NotImplementedError, "#{self.class} must implement #update"
28
33
  end
29
34
 
30
- # Remove a memory by id. Used by consolidation (FORGET).
31
- 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:)
32
38
  raise NotImplementedError, "#{self.class} must implement #delete"
33
39
  end
34
40
 
35
41
  # Update the last-accessed timestamp of a memory. Used by recency-aware recall.
36
- 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)
37
45
  raise NotImplementedError, "#{self.class} must implement #touch"
38
46
  end
39
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