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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +113 -0
- data/README.md +217 -11
- data/lib/engram/adapters/in_memory_processed_turns.rb +40 -8
- data/lib/engram/adapters/in_memory_store.rb +30 -11
- data/lib/engram/adapters/null_embedder.rb +9 -0
- data/lib/engram/adapters/pgvector_store.rb +50 -17
- data/lib/engram/adapters/ruby_llm_embedder.rb +45 -4
- data/lib/engram/consolidators/heuristic_consolidator.rb +7 -1
- data/lib/engram/consolidators/llm_consolidator.rb +37 -10
- data/lib/engram/embedding_metadata.rb +135 -0
- data/lib/engram/extraction.rb +30 -0
- data/lib/engram/extractors/llm_extractor.rb +4 -3
- data/lib/engram/internal/candidate_integrity.rb +510 -0
- data/lib/engram/internal/core_hash.rb +36 -0
- data/lib/engram/internal/scope.rb +31 -0
- data/lib/engram/memory.rb +28 -1
- data/lib/engram/persistence.rb +83 -8
- data/lib/engram/persistence_policy.rb +11 -1
- data/lib/engram/ports/consolidator.rb +7 -2
- data/lib/engram/ports/extractor.rb +1 -1
- data/lib/engram/ports/memory_store.rb +25 -7
- data/lib/engram/ports/processed_turns.rb +16 -8
- data/lib/engram/provenance.rb +588 -0
- data/lib/engram/rails/cache_processed_turns.rb +51 -10
- data/lib/engram/rails/observe_job.rb +5 -0
- data/lib/engram/rails/tasks.rake +26 -0
- data/lib/engram/railtie.rb +4 -0
- data/lib/engram/record.rb +12 -5
- data/lib/engram/reserved_metadata.rb +52 -0
- data/lib/engram/use_cases/forget.rb +6 -2
- data/lib/engram/use_cases/grounding_report.rb +44 -0
- data/lib/engram/use_cases/observe.rb +300 -26
- data/lib/engram/use_cases/rebuild_embeddings.rb +189 -0
- data/lib/engram/use_cases/recall.rb +12 -4
- data/lib/engram/use_cases/source_impact.rb +42 -0
- data/lib/engram/version.rb +1 -1
- data/lib/engram.rb +13 -0
- metadata +14 -3
|
@@ -0,0 +1,588 @@
|
|
|
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
|
+
# Includes arbitrary extension fields. Keeping this conservative leaves ample
|
|
15
|
+
# Ruby stack headroom while covering the schema's ordinary nesting.
|
|
16
|
+
MAX_PROVENANCE_NESTING = 100
|
|
17
|
+
private_constant :MAX_PROVENANCE_NESTING
|
|
18
|
+
|
|
19
|
+
class Span
|
|
20
|
+
OFFSET_UNIT = "unicode_codepoint"
|
|
21
|
+
|
|
22
|
+
attr_reader :start_offset, :end_offset, :offset_unit
|
|
23
|
+
|
|
24
|
+
def initialize(start_offset:, end_offset:, offset_unit: OFFSET_UNIT)
|
|
25
|
+
unless start_offset.is_a?(Integer) && end_offset.is_a?(Integer) &&
|
|
26
|
+
start_offset >= 0 && end_offset > start_offset
|
|
27
|
+
raise ArgumentError, "span offsets must be non-negative integers with end_offset > start_offset"
|
|
28
|
+
end
|
|
29
|
+
raise ArgumentError, "unsupported offset unit" unless offset_unit.to_s == OFFSET_UNIT
|
|
30
|
+
|
|
31
|
+
@start_offset = start_offset
|
|
32
|
+
@end_offset = end_offset
|
|
33
|
+
@offset_unit = OFFSET_UNIT
|
|
34
|
+
freeze
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def end_exclusive? = true
|
|
38
|
+
|
|
39
|
+
def ==(other)
|
|
40
|
+
other.is_a?(self.class) && to_h == other.to_h
|
|
41
|
+
end
|
|
42
|
+
alias_method :eql?, :==
|
|
43
|
+
|
|
44
|
+
def hash = to_h.hash
|
|
45
|
+
|
|
46
|
+
def to_h
|
|
47
|
+
{"start_offset" => start_offset, "end_offset" => end_offset, "offset_unit" => offset_unit}
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
class SourceText
|
|
52
|
+
attr_reader :source_id, :source_type, :message_index, :role, :text
|
|
53
|
+
|
|
54
|
+
def initialize(source_id:, source_type:, message_index:, role:, text:)
|
|
55
|
+
raise ArgumentError, "source_id is required" if source_id.to_s.strip.empty?
|
|
56
|
+
raise ArgumentError, "source_type is required" if source_type.to_s.strip.empty?
|
|
57
|
+
unless message_index.is_a?(Integer) && !message_index.negative?
|
|
58
|
+
raise ArgumentError, "message_index must be a non-negative integer"
|
|
59
|
+
end
|
|
60
|
+
raise ArgumentError, "role is required" if role.to_s.strip.empty?
|
|
61
|
+
raise ArgumentError, "source text must be a String" unless text.is_a?(String)
|
|
62
|
+
raise ArgumentError, "source text must be valid UTF-8" unless text.valid_encoding?
|
|
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
|
+
@text = text.encode(Encoding::UTF_8).freeze
|
|
69
|
+
freeze
|
|
70
|
+
rescue EncodingError
|
|
71
|
+
raise ArgumentError, "source text must be valid UTF-8"
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
class Source
|
|
76
|
+
attr_reader :source_id, :source_type, :message_index, :role, :spans, :alignment
|
|
77
|
+
|
|
78
|
+
def initialize(source_id:, source_type:, message_index:, role:, spans:, alignment:)
|
|
79
|
+
raise ArgumentError, "source_id is required" if source_id.to_s.strip.empty?
|
|
80
|
+
raise ArgumentError, "source_type is required" if source_type.to_s.strip.empty?
|
|
81
|
+
unless message_index.is_a?(Integer) && !message_index.negative?
|
|
82
|
+
raise ArgumentError, "message_index must be a non-negative integer"
|
|
83
|
+
end
|
|
84
|
+
raise ArgumentError, "role is required" if role.to_s.strip.empty?
|
|
85
|
+
|
|
86
|
+
unless alignment.is_a?(String) || alignment.is_a?(Symbol)
|
|
87
|
+
raise ArgumentError, "unknown alignment #{alignment.inspect}"
|
|
88
|
+
end
|
|
89
|
+
normalized_alignment = alignment.to_sym
|
|
90
|
+
raise ArgumentError, "unknown alignment #{alignment.inspect}" unless ALIGNMENTS.include?(normalized_alignment)
|
|
91
|
+
|
|
92
|
+
@source_id = source_id.to_s.dup.freeze
|
|
93
|
+
@source_type = source_type.to_s.dup.freeze
|
|
94
|
+
@message_index = message_index
|
|
95
|
+
@role = role.to_s.dup.freeze
|
|
96
|
+
raise ArgumentError, "spans must be an array" unless spans.is_a?(Array)
|
|
97
|
+
raise ArgumentError, "spans must contain at least one span" if spans.empty?
|
|
98
|
+
@spans = spans.map do |span|
|
|
99
|
+
raise ArgumentError, "spans must contain Provenance::Span values" unless span.is_a?(Span)
|
|
100
|
+
span
|
|
101
|
+
end.freeze
|
|
102
|
+
@alignment = normalized_alignment
|
|
103
|
+
freeze
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def validate_source_text!(source_text)
|
|
107
|
+
unless source_text.is_a?(SourceText)
|
|
108
|
+
raise ArgumentError, "source_text must be a Provenance::SourceText"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
{
|
|
112
|
+
source_id: source_id,
|
|
113
|
+
source_type: source_type,
|
|
114
|
+
message_index: message_index,
|
|
115
|
+
role: role
|
|
116
|
+
}.each do |attribute, expected|
|
|
117
|
+
actual = source_text.public_send(attribute)
|
|
118
|
+
next if actual == expected
|
|
119
|
+
|
|
120
|
+
raise Engram::Error, "provenance #{attribute} does not match source text"
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
source_length = source_text.text.length
|
|
124
|
+
spans.each_with_index do |span, index|
|
|
125
|
+
next if span.end_offset <= source_length
|
|
126
|
+
|
|
127
|
+
raise Engram::Error,
|
|
128
|
+
"provenance span #{index} ends at #{span.end_offset} beyond source length #{source_length}"
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
true
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def supporting_text(source_text)
|
|
135
|
+
validate_source_text!(source_text)
|
|
136
|
+
|
|
137
|
+
spans.map do |span|
|
|
138
|
+
source_text.text[span.start_offset...span.end_offset].freeze
|
|
139
|
+
end.freeze
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def ==(other)
|
|
143
|
+
other.is_a?(self.class) && to_h == other.to_h
|
|
144
|
+
end
|
|
145
|
+
alias_method :eql?, :==
|
|
146
|
+
|
|
147
|
+
def hash = to_h.hash
|
|
148
|
+
|
|
149
|
+
def to_h
|
|
150
|
+
data = {
|
|
151
|
+
"source_id" => source_id,
|
|
152
|
+
"source_type" => source_type,
|
|
153
|
+
"spans" => spans.map(&:to_h),
|
|
154
|
+
"alignment" => alignment.to_s
|
|
155
|
+
}
|
|
156
|
+
data["message_index"] = message_index
|
|
157
|
+
data["role"] = role
|
|
158
|
+
data
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
class Extractor
|
|
163
|
+
attr_reader :name, :provider, :model
|
|
164
|
+
|
|
165
|
+
def initialize(name:, model:, provider: nil)
|
|
166
|
+
raise ArgumentError, "extractor name is required" if name.to_s.strip.empty?
|
|
167
|
+
raise ArgumentError, "extractor model is required" if model.to_s.strip.empty?
|
|
168
|
+
|
|
169
|
+
@name = name.to_s.dup.freeze
|
|
170
|
+
@provider = provider&.to_s&.dup&.freeze
|
|
171
|
+
@model = model.to_s.dup.freeze
|
|
172
|
+
freeze
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def ==(other)
|
|
176
|
+
other.is_a?(self.class) && to_h == other.to_h
|
|
177
|
+
end
|
|
178
|
+
alias_method :eql?, :==
|
|
179
|
+
|
|
180
|
+
def hash = to_h.hash
|
|
181
|
+
|
|
182
|
+
def to_h
|
|
183
|
+
{"name" => name, "model" => model}.tap do |data|
|
|
184
|
+
data["provider"] = provider if provider
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
attr_reader :sources, :extractor, :confidence
|
|
190
|
+
|
|
191
|
+
def initialize(sources:, extractor:, confidence:)
|
|
192
|
+
raise ArgumentError, "sources must be an array" unless sources.is_a?(Array)
|
|
193
|
+
raise ArgumentError, "sources must contain at least one source" if sources.empty?
|
|
194
|
+
@sources = sources.map do |source|
|
|
195
|
+
raise ArgumentError, "sources must contain Provenance::Source values" unless source.is_a?(Source)
|
|
196
|
+
source
|
|
197
|
+
end.freeze
|
|
198
|
+
raise ArgumentError, "extractor must be a Provenance::Extractor" unless extractor.is_a?(Extractor)
|
|
199
|
+
valid_confidence = (confidence.is_a?(Integer) || confidence.is_a?(Float)) && confidence.between?(0, 1)
|
|
200
|
+
unless valid_confidence
|
|
201
|
+
raise ArgumentError, "confidence must be an Integer or Float between 0 and 1"
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
@extractor = extractor
|
|
205
|
+
@confidence = confidence
|
|
206
|
+
freeze
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
def ==(other)
|
|
210
|
+
other.is_a?(self.class) && to_h == other.to_h
|
|
211
|
+
end
|
|
212
|
+
alias_method :eql?, :==
|
|
213
|
+
|
|
214
|
+
def hash = to_h.hash
|
|
215
|
+
|
|
216
|
+
def to_h
|
|
217
|
+
{
|
|
218
|
+
"version" => SCHEMA_VERSION,
|
|
219
|
+
"sources" => sources.map(&:to_h),
|
|
220
|
+
"extractor" => extractor.to_h,
|
|
221
|
+
"confidence" => confidence
|
|
222
|
+
}
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def ungrounded?
|
|
226
|
+
sources.any? { |source| source.alignment == :ungrounded }
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
class << self
|
|
230
|
+
def attach(metadata, provenance)
|
|
231
|
+
raise ArgumentError, "provenance must be a Provenance value" unless provenance.is_a?(self)
|
|
232
|
+
|
|
233
|
+
Engram::ReservedMetadata.attach(metadata, METADATA_KEY, provenance.to_h)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
def extract(metadata)
|
|
237
|
+
extract_for_persistence(metadata)
|
|
238
|
+
rescue Engram::Error
|
|
239
|
+
nil
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
# Strict parser used by writers. Ordinary reads intentionally use #extract and
|
|
243
|
+
# tolerate malformed or future schemas, but an older writer must not silently
|
|
244
|
+
# persist provenance it cannot validate.
|
|
245
|
+
def extract_for_persistence(metadata)
|
|
246
|
+
data = canonical_payload_for_persistence(metadata)
|
|
247
|
+
data && from_h(data)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# Returns a behavior-free canonical copy of the complete validated payload,
|
|
251
|
+
# including extension fields which the value object intentionally does not expose.
|
|
252
|
+
def canonical_payload_for_persistence(metadata)
|
|
253
|
+
metadata ||= {}
|
|
254
|
+
return nil unless core_kind_of?(metadata, Hash)
|
|
255
|
+
|
|
256
|
+
reserved_values = core_hash_values(metadata, RESERVED_KEY, :_engram)
|
|
257
|
+
reserved_hashes = reserved_values.select { |value| core_kind_of?(value, Hash) }
|
|
258
|
+
provenance_values = reserved_hashes
|
|
259
|
+
.flat_map { |reserved| core_hash_values(reserved, METADATA_KEY, :provenance) }
|
|
260
|
+
if provenance_values.empty?
|
|
261
|
+
reject_non_hash_reserved_values!(reserved_values)
|
|
262
|
+
return nil
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
reserved = provenance_values.reduce({}) do |merged, value|
|
|
266
|
+
detached = detach_provenance_container(value)
|
|
267
|
+
Engram::ReservedMetadata.merge(merged, METADATA_KEY => detached)
|
|
268
|
+
end
|
|
269
|
+
data = reserved.fetch(METADATA_KEY)
|
|
270
|
+
unless data.is_a?(Hash)
|
|
271
|
+
raise Engram::Error, "malformed provenance at _engram.provenance: expected an object"
|
|
272
|
+
end
|
|
273
|
+
unless data.key?("version")
|
|
274
|
+
raise Engram::Error, "malformed provenance at _engram.provenance.version: version is required"
|
|
275
|
+
end
|
|
276
|
+
unless data["version"] == SCHEMA_VERSION
|
|
277
|
+
raise Engram::Error, "unsupported provenance version #{data["version"].inspect}"
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
# Validate the known schema while retaining the already detached extension data.
|
|
281
|
+
from_h(data)
|
|
282
|
+
reject_non_hash_reserved_values!(reserved_values)
|
|
283
|
+
data
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# Replaces validated provenance aliases with the detached payload in a plain
|
|
287
|
+
# reserved namespace. Unrelated application metadata values remain untouched.
|
|
288
|
+
def canonical_metadata_for_persistence(metadata)
|
|
289
|
+
payload = canonical_payload_for_persistence(metadata)
|
|
290
|
+
return metadata unless payload
|
|
291
|
+
|
|
292
|
+
# Copy the core table without rehashing arbitrary application keys, then
|
|
293
|
+
# remove only the reserved aliases through bound Hash traversal.
|
|
294
|
+
application_metadata = Hash.instance_method(:transform_values).bind_call(metadata) { |value| value }
|
|
295
|
+
Hash.instance_method(:delete_if).bind_call(application_metadata) do |key, _value|
|
|
296
|
+
reserved_key_style(key)
|
|
297
|
+
end
|
|
298
|
+
string_reserved = []
|
|
299
|
+
symbol_reserved = []
|
|
300
|
+
Engram::Internal::CoreHash.each_pair(metadata) do |key, value|
|
|
301
|
+
case reserved_key_style(key)
|
|
302
|
+
when :string then string_reserved << value
|
|
303
|
+
when :symbol then symbol_reserved << value
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
reserved = (string_reserved + symbol_reserved).reduce({}) do |merged, value|
|
|
308
|
+
siblings = {}
|
|
309
|
+
Engram::Internal::CoreHash.each_pair(value) do |key, nested|
|
|
310
|
+
siblings[key] = nested unless provenance_key_alias?(key)
|
|
311
|
+
end
|
|
312
|
+
Engram::ReservedMetadata.merge(merged, Engram::ReservedMetadata.normalize(siblings))
|
|
313
|
+
end
|
|
314
|
+
application_metadata[RESERVED_KEY] = reserved.merge(METADATA_KEY => payload)
|
|
315
|
+
application_metadata
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# Represents the complete canonical payload without relying on value #==, #eql?,
|
|
319
|
+
# or #hash implementations. Explicit structural and scalar tags preserve key,
|
|
320
|
+
# value, and container roles. Integers remain distinct from Floats, whose packed
|
|
321
|
+
# IEEE 754 bits preserve distinctions such as negative zero.
|
|
322
|
+
def canonical_integrity_representation_for_persistence(metadata)
|
|
323
|
+
data = canonical_payload_for_persistence(metadata)
|
|
324
|
+
data && provenance_integrity_representation(data)
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
private
|
|
328
|
+
|
|
329
|
+
def reject_non_hash_reserved_values!(reserved_values)
|
|
330
|
+
return if reserved_values.all? { |value| core_kind_of?(value, Hash) }
|
|
331
|
+
|
|
332
|
+
raise Engram::Error, "metadata key #{RESERVED_KEY.inspect} is reserved for Engram embedding metadata"
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def reserved_key_style(key)
|
|
336
|
+
key_class = Object.instance_method(:class).bind_call(key)
|
|
337
|
+
return :symbol if key.equal?(:_engram) && key_class.equal?(Symbol)
|
|
338
|
+
:string if core_kind_of?(key, String) && String.instance_method(:==).bind_call(key, RESERVED_KEY)
|
|
339
|
+
rescue TypeError
|
|
340
|
+
nil
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
def provenance_key_alias?(key)
|
|
344
|
+
core_key_equal?(key, METADATA_KEY, :provenance)
|
|
345
|
+
end
|
|
346
|
+
|
|
347
|
+
# Finds trusted namespace keys through Hash's implementation rather than any
|
|
348
|
+
# behavior supplied by a Hash subclass or singleton class.
|
|
349
|
+
def core_hash_values(hash, string_key, symbol_key)
|
|
350
|
+
values = []
|
|
351
|
+
Engram::Internal::CoreHash.each_pair(hash) do |key, value|
|
|
352
|
+
values << value if core_key_equal?(key, string_key, symbol_key)
|
|
353
|
+
end
|
|
354
|
+
values
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
def core_key_equal?(key, string_key, symbol_key)
|
|
358
|
+
key_class = Object.instance_method(:class).bind_call(key)
|
|
359
|
+
return key.equal?(symbol_key) if key_class.equal?(Symbol)
|
|
360
|
+
return false unless core_kind_of?(key, String)
|
|
361
|
+
|
|
362
|
+
# Compare the stored String bytes without dispatching subclass #==/#eql?.
|
|
363
|
+
String.instance_method(:==).bind_call(key, string_key)
|
|
364
|
+
rescue TypeError
|
|
365
|
+
false
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
# Detaches the provenance subtree into plain containers and trusted primitive
|
|
369
|
+
# leaves. Container subclasses retain their stored contents, while Strings are
|
|
370
|
+
# exposed without subclass behavior and canonicalized to UTF-8. Other metadata
|
|
371
|
+
# is deliberately left untouched.
|
|
372
|
+
def detach_provenance_container(value, active = {}.compare_by_identity, depth = 0)
|
|
373
|
+
if core_kind_of?(value, Hash)
|
|
374
|
+
with_acyclic_container(value, active) do
|
|
375
|
+
copy = {}
|
|
376
|
+
Engram::Internal::CoreHash.each_pair(value) do |key, nested|
|
|
377
|
+
normalized_key = provenance_key(key)
|
|
378
|
+
normalized_value = detach_nested_provenance(nested, active, depth)
|
|
379
|
+
if copy.key?(normalized_key)
|
|
380
|
+
# Both collision inputs have already passed the same depth bound;
|
|
381
|
+
# merging them cannot introduce a deeper path than either input.
|
|
382
|
+
copy.replace(Engram::ReservedMetadata.merge(copy, normalized_key => normalized_value))
|
|
383
|
+
else
|
|
384
|
+
copy[normalized_key] = normalized_value
|
|
385
|
+
end
|
|
386
|
+
end
|
|
387
|
+
copy
|
|
388
|
+
end
|
|
389
|
+
elsif core_kind_of?(value, Array)
|
|
390
|
+
with_acyclic_container(value, active) do
|
|
391
|
+
copy = []
|
|
392
|
+
Array.instance_method(:each).bind_call(value) do |nested|
|
|
393
|
+
copy << detach_nested_provenance(nested, active, depth)
|
|
394
|
+
end
|
|
395
|
+
copy
|
|
396
|
+
end
|
|
397
|
+
else
|
|
398
|
+
canonical_provenance_scalar(value)
|
|
399
|
+
end
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def detach_nested_provenance(value, active, parent_depth)
|
|
403
|
+
if parent_depth >= MAX_PROVENANCE_NESTING && provenance_container?(value)
|
|
404
|
+
raise Engram::Error,
|
|
405
|
+
"malformed provenance at _engram.provenance: nesting exceeds maximum depth of #{MAX_PROVENANCE_NESTING}"
|
|
406
|
+
end
|
|
407
|
+
|
|
408
|
+
detach_provenance_container(value, active, parent_depth + 1)
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
def provenance_container?(value)
|
|
412
|
+
core_kind_of?(value, Hash) || core_kind_of?(value, Array)
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
def canonical_provenance_scalar(value)
|
|
416
|
+
value_class = Object.instance_method(:class).bind_call(value)
|
|
417
|
+
return nil if value_class.equal?(NilClass)
|
|
418
|
+
return true if value_class.equal?(TrueClass)
|
|
419
|
+
return false if value_class.equal?(FalseClass)
|
|
420
|
+
return value if value_class.equal?(Symbol) || value_class.equal?(Integer)
|
|
421
|
+
if value_class.equal?(Float)
|
|
422
|
+
unless Float.instance_method(:finite?).bind_call(value)
|
|
423
|
+
raise Engram::Error, "malformed provenance: scalar values must be JSON-native primitives"
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
return value
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
if core_kind_of?(value, String)
|
|
430
|
+
# Bound String#to_s exposes a subclass's underlying string as an exact String;
|
|
431
|
+
# bound #dup then removes any singleton methods from an exact String value.
|
|
432
|
+
string = String.instance_method(:to_s).bind_call(value)
|
|
433
|
+
string = canonical_utf8_string(string)
|
|
434
|
+
unless string
|
|
435
|
+
raise Engram::Error, "malformed provenance: String values must have valid encoding"
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
return String.instance_method(:dup).bind_call(string)
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
raise Engram::Error, "malformed provenance: scalar values must be JSON-native primitives"
|
|
442
|
+
rescue TypeError
|
|
443
|
+
raise Engram::Error, "malformed provenance: scalar values must be JSON-native primitives"
|
|
444
|
+
end
|
|
445
|
+
|
|
446
|
+
def provenance_integrity_representation(value)
|
|
447
|
+
value_class = Object.instance_method(:class).bind_call(value)
|
|
448
|
+
if value_class.equal?(Hash)
|
|
449
|
+
fields = {}
|
|
450
|
+
Engram::Internal::CoreHash.each_pair(value) do |key, nested|
|
|
451
|
+
fields[key] = provenance_integrity_representation(nested)
|
|
452
|
+
end
|
|
453
|
+
return [:object, fields]
|
|
454
|
+
end
|
|
455
|
+
if value_class.equal?(Array)
|
|
456
|
+
entries = []
|
|
457
|
+
Array.instance_method(:each).bind_call(value) do |nested|
|
|
458
|
+
entries << provenance_integrity_representation(nested)
|
|
459
|
+
end
|
|
460
|
+
return [:array, entries]
|
|
461
|
+
end
|
|
462
|
+
return [:null] if value_class.equal?(NilClass)
|
|
463
|
+
return [:boolean, true] if value_class.equal?(TrueClass)
|
|
464
|
+
return [:boolean, false] if value_class.equal?(FalseClass)
|
|
465
|
+
if value_class.equal?(Integer)
|
|
466
|
+
return [:integer, Integer.instance_method(:to_s).bind_call(value)]
|
|
467
|
+
end
|
|
468
|
+
if value_class.equal?(Float)
|
|
469
|
+
bits = Array.instance_method(:pack).bind_call([value], "G")
|
|
470
|
+
return [:float, bits]
|
|
471
|
+
end
|
|
472
|
+
return [:symbol, Symbol.instance_method(:to_s).bind_call(value)] if value_class.equal?(Symbol)
|
|
473
|
+
return [:string, value] if value_class.equal?(String)
|
|
474
|
+
|
|
475
|
+
# canonical_payload_for_persistence has already rejected every other type.
|
|
476
|
+
raise Engram::Error, "malformed provenance: scalar values must be JSON-native primitives"
|
|
477
|
+
rescue TypeError
|
|
478
|
+
raise Engram::Error, "malformed provenance: scalar values must be JSON-native primitives"
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
def provenance_key(key)
|
|
482
|
+
key_class = Object.instance_method(:class).bind_call(key)
|
|
483
|
+
string = if key_class.equal?(Symbol)
|
|
484
|
+
Symbol.instance_method(:to_s).bind_call(key)
|
|
485
|
+
elsif core_kind_of?(key, String)
|
|
486
|
+
# Bound String#to_s returns the underlying bytes as an exact String without
|
|
487
|
+
# dispatching subclass behavior; #dup strips exact-String singleton methods.
|
|
488
|
+
String.instance_method(:to_s).bind_call(key)
|
|
489
|
+
else
|
|
490
|
+
raise Engram::Error, "malformed provenance: object keys must be String or Symbol values"
|
|
491
|
+
end
|
|
492
|
+
string = canonical_utf8_string(string)
|
|
493
|
+
unless string
|
|
494
|
+
raise Engram::Error, "malformed provenance: object keys must have valid encoding"
|
|
495
|
+
end
|
|
496
|
+
|
|
497
|
+
String.instance_method(:dup).bind_call(string)
|
|
498
|
+
rescue TypeError
|
|
499
|
+
raise Engram::Error, "malformed provenance: object keys must be String or Symbol values"
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
def canonical_utf8_string(string)
|
|
503
|
+
return unless String.instance_method(:valid_encoding?).bind_call(string)
|
|
504
|
+
|
|
505
|
+
String.instance_method(:encode).bind_call(string, Encoding::UTF_8)
|
|
506
|
+
rescue EncodingError
|
|
507
|
+
nil
|
|
508
|
+
end
|
|
509
|
+
|
|
510
|
+
def with_acyclic_container(value, active)
|
|
511
|
+
raise Engram::Error, "malformed provenance: cyclic containers are unsupported" if active.key?(value)
|
|
512
|
+
|
|
513
|
+
active[value] = true
|
|
514
|
+
yield
|
|
515
|
+
ensure
|
|
516
|
+
active.delete(value)
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
def core_kind_of?(value, klass)
|
|
520
|
+
Object.instance_method(:is_a?).bind_call(value, klass)
|
|
521
|
+
rescue TypeError
|
|
522
|
+
false
|
|
523
|
+
end
|
|
524
|
+
|
|
525
|
+
def from_h(data)
|
|
526
|
+
sources = provenance_array(data["sources"], "_engram.provenance.sources")
|
|
527
|
+
extractor_data = provenance_hash(data["extractor"], "_engram.provenance.extractor")
|
|
528
|
+
|
|
529
|
+
parsed_sources = sources.each_with_index.map do |source, source_index|
|
|
530
|
+
source_path = "_engram.provenance.sources[#{source_index}]"
|
|
531
|
+
source = provenance_hash(source, source_path)
|
|
532
|
+
spans = provenance_array(source["spans"], "#{source_path}.spans")
|
|
533
|
+
parsed_spans = spans.each_with_index.map do |span, span_index|
|
|
534
|
+
span_path = "#{source_path}.spans[#{span_index}]"
|
|
535
|
+
span = provenance_hash(span, span_path)
|
|
536
|
+
build_provenance_value(span_path) do
|
|
537
|
+
Span.new(
|
|
538
|
+
start_offset: span["start_offset"],
|
|
539
|
+
end_offset: span["end_offset"],
|
|
540
|
+
offset_unit: span["offset_unit"]
|
|
541
|
+
)
|
|
542
|
+
end
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
build_provenance_value(source_path) do
|
|
546
|
+
Source.new(
|
|
547
|
+
source_id: source["source_id"],
|
|
548
|
+
source_type: source["source_type"],
|
|
549
|
+
message_index: source["message_index"],
|
|
550
|
+
role: source["role"],
|
|
551
|
+
spans: parsed_spans,
|
|
552
|
+
alignment: source["alignment"]
|
|
553
|
+
)
|
|
554
|
+
end
|
|
555
|
+
end
|
|
556
|
+
|
|
557
|
+
extractor = build_provenance_value("_engram.provenance.extractor") do
|
|
558
|
+
Extractor.new(**symbolize_extractor(extractor_data))
|
|
559
|
+
end
|
|
560
|
+
build_provenance_value("_engram.provenance") do
|
|
561
|
+
new(sources: parsed_sources, extractor: extractor, confidence: data["confidence"])
|
|
562
|
+
end
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
def provenance_hash(value, path)
|
|
566
|
+
return value if value.is_a?(Hash)
|
|
567
|
+
|
|
568
|
+
raise Engram::Error, "malformed provenance at #{path}: expected an object"
|
|
569
|
+
end
|
|
570
|
+
|
|
571
|
+
def provenance_array(value, path)
|
|
572
|
+
return value if value.is_a?(Array)
|
|
573
|
+
|
|
574
|
+
raise Engram::Error, "malformed provenance at #{path}: expected an array"
|
|
575
|
+
end
|
|
576
|
+
|
|
577
|
+
def build_provenance_value(path)
|
|
578
|
+
yield
|
|
579
|
+
rescue ArgumentError, TypeError, KeyError, NoMethodError, EncodingError => error
|
|
580
|
+
raise Engram::Error, "malformed provenance at #{path}: #{error.message}"
|
|
581
|
+
end
|
|
582
|
+
|
|
583
|
+
def symbolize_extractor(data)
|
|
584
|
+
{name: data["name"], provider: data["provider"], model: data["model"]}
|
|
585
|
+
end
|
|
586
|
+
end
|
|
587
|
+
end
|
|
588
|
+
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
|
-
#
|
|
6
|
-
#
|
|
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
|
-
|
|
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
|
|
16
|
-
|
|
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
|
|
20
|
-
|
|
21
|
-
|
|
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
|
|
27
|
-
|
|
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
|