engram 0.5.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 +56 -0
- data/README.md +123 -3
- data/lib/engram/adapters/in_memory_store.rb +5 -1
- data/lib/engram/adapters/pgvector_store.rb +15 -0
- data/lib/engram/extraction.rb +30 -0
- 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 +15 -0
- data/lib/engram/persistence.rb +79 -9
- 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 +11 -0
- data/lib/engram/provenance.rb +349 -18
- data/lib/engram/record.rb +12 -5
- data/lib/engram/use_cases/grounding_report.rb +44 -0
- data/lib/engram/use_cases/observe.rb +266 -13
- data/lib/engram/use_cases/source_impact.rb +42 -0
- data/lib/engram/version.rb +1 -1
- data/lib/engram.rb +6 -0
- metadata +8 -2
data/lib/engram/persistence.rb
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
module Engram
|
|
4
4
|
# Applies persistence hooks and policy consistently before writing records.
|
|
5
5
|
class Persistence
|
|
6
|
+
RECORD_METADATA_READER = Engram::Record.instance_method(:metadata)
|
|
7
|
+
|
|
6
8
|
def initialize(store:, embedder:, before_persist: Engram.config.before_persist,
|
|
7
9
|
persistence_policy: Engram.config.persistence_policy)
|
|
8
10
|
@store = store
|
|
@@ -12,28 +14,96 @@ module Engram
|
|
|
12
14
|
end
|
|
13
15
|
|
|
14
16
|
def add(record, scope: record.scope)
|
|
15
|
-
|
|
16
|
-
if record && record.scope != scope
|
|
17
|
-
raise Engram::Error, "cannot move memory across scopes"
|
|
18
|
-
end
|
|
19
|
-
@store.add(record) if record
|
|
17
|
+
add_prepared(prepare(record), scope: scope)
|
|
20
18
|
end
|
|
21
19
|
|
|
22
20
|
def update(scope:, id:, record:)
|
|
23
|
-
record
|
|
24
|
-
@store.update(scope: scope, id: id, record: record) if record
|
|
21
|
+
update_prepared(scope: scope, id: id, record: prepare(record))
|
|
25
22
|
end
|
|
26
23
|
|
|
27
|
-
|
|
24
|
+
# Persists a record already returned by #prepare. These split-phase methods let
|
|
25
|
+
# orchestrators prepare a complete batch before beginning store mutations.
|
|
26
|
+
def add_prepared(record, scope: record&.scope)
|
|
27
|
+
return unless record
|
|
28
|
+
|
|
29
|
+
record = canonicalize_provenance!(record)
|
|
30
|
+
unless Engram::Internal::Scope.record_matches?(record, scope)
|
|
31
|
+
raise Engram::Error, "cannot move memory across scopes"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
@store.add(record)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def update_prepared(scope:, id:, record:)
|
|
38
|
+
return unless record
|
|
39
|
+
|
|
40
|
+
record = canonicalize_provenance!(record)
|
|
41
|
+
unless Engram::Internal::Scope.record_matches?(record, scope)
|
|
42
|
+
raise Engram::Error, "cannot move memory across scopes"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
@store.update(scope: scope, id: id, record: record)
|
|
46
|
+
end
|
|
28
47
|
|
|
48
|
+
# Applies all write transformations without mutating the store. Input is validated
|
|
49
|
+
# before callbacks can remove or replace provenance, and final output is validated too.
|
|
29
50
|
def prepare(record)
|
|
51
|
+
original_provenance = validate_provenance!(record)
|
|
30
52
|
original_content = record.content
|
|
31
53
|
record = @before_persist.call(record) if @before_persist
|
|
54
|
+
if record
|
|
55
|
+
transformed_provenance = validate_provenance!(record)
|
|
56
|
+
validate_provenance_trust!(original_provenance, transformed_provenance) if @before_persist
|
|
57
|
+
end
|
|
32
58
|
record = @persistence_policy.call(record) if record && @persistence_policy
|
|
59
|
+
validate_provenance!(record) if record
|
|
33
60
|
if record && record.content != original_content
|
|
34
61
|
record = record.with(embedding: @embedder.embed(record.content))
|
|
35
62
|
end
|
|
36
|
-
record
|
|
63
|
+
record = Engram::EmbeddingMetadata.attach(record, embedder: @embedder) if record
|
|
64
|
+
validate_provenance!(record) if record
|
|
65
|
+
record
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Authorizes a destructive decision without applying write transformations or content
|
|
69
|
+
# filtering. Policies may opt into this separate contract with #allow_destructive?.
|
|
70
|
+
# Malformed provenance always fails closed.
|
|
71
|
+
def allowed?(record)
|
|
72
|
+
validate_provenance!(record)
|
|
73
|
+
return true unless @persistence_policy&.respond_to?(:allow_destructive?)
|
|
74
|
+
|
|
75
|
+
authorization = @persistence_policy.allow_destructive?(record)
|
|
76
|
+
unless authorization.equal?(true) || authorization.equal?(false)
|
|
77
|
+
raise Engram::Error, "persistence policy allow_destructive? must return true or false"
|
|
78
|
+
end
|
|
79
|
+
validate_provenance!(record)
|
|
80
|
+
|
|
81
|
+
authorization
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def canonicalize_provenance!(record)
|
|
87
|
+
metadata = RECORD_METADATA_READER.bind_call(record)
|
|
88
|
+
canonical_metadata = Engram::Provenance.canonical_metadata_for_persistence(metadata)
|
|
89
|
+
return record if canonical_metadata.equal?(metadata)
|
|
90
|
+
|
|
91
|
+
record.with(metadata: canonical_metadata)
|
|
92
|
+
rescue TypeError
|
|
93
|
+
raise Engram::Error, "persistence requires an Engram::Record"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def validate_provenance!(record)
|
|
97
|
+
metadata = RECORD_METADATA_READER.bind_call(record)
|
|
98
|
+
Engram::Provenance.canonical_integrity_representation_for_persistence(metadata)
|
|
99
|
+
rescue TypeError
|
|
100
|
+
raise Engram::Error, "persistence requires an Engram::Record"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def validate_provenance_trust!(original, transformed)
|
|
104
|
+
return if original == transformed
|
|
105
|
+
|
|
106
|
+
raise Engram::Error, "before_persist cannot change provenance trust"
|
|
37
107
|
end
|
|
38
108
|
end
|
|
39
109
|
end
|
|
@@ -16,16 +16,26 @@ module Engram
|
|
|
16
16
|
/\b(?:today|now|this session)\b.*\b(?:fixed|resolved|done|completed|finished)\b/i
|
|
17
17
|
].freeze
|
|
18
18
|
|
|
19
|
-
def initialize(denylist_patterns: [])
|
|
19
|
+
def initialize(denylist_patterns: [], allow_ungrounded: false)
|
|
20
20
|
@denylist_patterns = denylist_patterns
|
|
21
|
+
@allow_ungrounded = BasicObject.instance_method(:equal?).bind_call(allow_ungrounded, true)
|
|
21
22
|
end
|
|
22
23
|
|
|
23
24
|
def call(record)
|
|
25
|
+
provenance = Engram::Provenance.extract_for_persistence(record.metadata)
|
|
26
|
+
return nil if provenance&.ungrounded? && !@allow_ungrounded
|
|
24
27
|
return nil if reject?(record.content)
|
|
25
28
|
|
|
26
29
|
redact(record)
|
|
27
30
|
end
|
|
28
31
|
|
|
32
|
+
# Destructive authorization is deliberately independent from write-content filtering and
|
|
33
|
+
# redaction. Provenance trust still applies, but secret/transient content can always be removed.
|
|
34
|
+
def allow_destructive?(record)
|
|
35
|
+
provenance = Engram::Provenance.extract_for_persistence(record.metadata)
|
|
36
|
+
!provenance&.ungrounded? || @allow_ungrounded
|
|
37
|
+
end
|
|
38
|
+
|
|
29
39
|
private
|
|
30
40
|
|
|
31
41
|
def reject?(content)
|
|
@@ -7,8 +7,13 @@ module Engram
|
|
|
7
7
|
# dumb pile of embeddings.
|
|
8
8
|
# Implementations: Consolidators::HeuristicConsolidator, Consolidators::LLMConsolidator.
|
|
9
9
|
module Consolidator
|
|
10
|
-
# Given Array<Record> candidates and a scope, return Array<Decision> (one
|
|
11
|
-
# candidate
|
|
10
|
+
# Given Array<Record> candidates and a scope, return Array<Decision> (at most one
|
|
11
|
+
# per candidate occurrence, including NOOP decisions). Each decision must reference
|
|
12
|
+
# the actual candidate instance from this array, not a copy or replacement. When the
|
|
13
|
+
# same instance occurs multiple times, it may have no more decisions than occurrences.
|
|
14
|
+
# Candidates are read-only reconciliation inputs: implementations must not mutate
|
|
15
|
+
# their state, including nested metadata or embedding values. Decision target IDs
|
|
16
|
+
# must be plain String or Integer values without singleton behavior or custom state.
|
|
12
17
|
def reconcile_all(candidates:, scope:)
|
|
13
18
|
raise NotImplementedError, "#{self.class} must implement #reconcile_all"
|
|
14
19
|
end
|
|
@@ -6,7 +6,7 @@ module Engram
|
|
|
6
6
|
# Declared now so the differentiator (extract -> consolidate) slots in without
|
|
7
7
|
# reworking the core. Not implemented in v0.1.
|
|
8
8
|
module Extractor
|
|
9
|
-
# Given conversation messages, return Array
|
|
9
|
+
# Given conversation messages, return an Array whose members are Record or Extraction values.
|
|
10
10
|
def extract(messages:, scope:)
|
|
11
11
|
raise NotImplementedError, "Extractor arrives in v0.2"
|
|
12
12
|
end
|
|
@@ -25,6 +25,17 @@ module Engram
|
|
|
25
25
|
raise NotImplementedError, "#{self.class} must implement #all"
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
+
# Optional performance capability: return the subset of requested ids that
|
|
29
|
+
# exists in `scope`, preserving each requested value's representation rather
|
|
30
|
+
# than returning a store-native cast of it. Return at most one requested
|
|
31
|
+
# representation for each persisted record when the store accepts aliases.
|
|
32
|
+
# Callers must fall back
|
|
33
|
+
# to #all for legacy stores that do not expose this method or leave it
|
|
34
|
+
# unimplemented, so adding it does not break custom MemoryStore adapters.
|
|
35
|
+
def existing_ids(scope:, ids:)
|
|
36
|
+
raise NotImplementedError, "#{self.class} does not implement #existing_ids"
|
|
37
|
+
end
|
|
38
|
+
|
|
28
39
|
# Replace the content/embedding of an existing memory. Used by consolidation
|
|
29
40
|
# (UPDATE). Returns the updated Record. Raises Engram::Error when the scoped
|
|
30
41
|
# record does not exist or the replacement would move it to another scope.
|
data/lib/engram/provenance.rb
CHANGED
|
@@ -11,6 +11,10 @@ module Engram
|
|
|
11
11
|
METADATA_KEY = "provenance"
|
|
12
12
|
SCHEMA_VERSION = 1
|
|
13
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
|
|
14
18
|
|
|
15
19
|
class Span
|
|
16
20
|
OFFSET_UNIT = "unicode_codepoint"
|
|
@@ -44,6 +48,30 @@ module Engram
|
|
|
44
48
|
end
|
|
45
49
|
end
|
|
46
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
|
+
|
|
47
75
|
class Source
|
|
48
76
|
attr_reader :source_id, :source_type, :message_index, :role, :spans, :alignment
|
|
49
77
|
|
|
@@ -75,6 +103,42 @@ module Engram
|
|
|
75
103
|
freeze
|
|
76
104
|
end
|
|
77
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
|
+
|
|
78
142
|
def ==(other)
|
|
79
143
|
other.is_a?(self.class) && to_h == other.to_h
|
|
80
144
|
end
|
|
@@ -158,6 +222,10 @@ module Engram
|
|
|
158
222
|
}
|
|
159
223
|
end
|
|
160
224
|
|
|
225
|
+
def ungrounded?
|
|
226
|
+
sources.any? { |source| source.alignment == :ungrounded }
|
|
227
|
+
end
|
|
228
|
+
|
|
161
229
|
class << self
|
|
162
230
|
def attach(metadata, provenance)
|
|
163
231
|
raise ArgumentError, "provenance must be a Provenance value" unless provenance.is_a?(self)
|
|
@@ -166,20 +234,294 @@ module Engram
|
|
|
166
234
|
end
|
|
167
235
|
|
|
168
236
|
def extract(metadata)
|
|
169
|
-
|
|
170
|
-
|
|
237
|
+
extract_for_persistence(metadata)
|
|
238
|
+
rescue Engram::Error
|
|
239
|
+
nil
|
|
240
|
+
end
|
|
171
241
|
|
|
172
|
-
|
|
173
|
-
|
|
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
|
|
174
249
|
|
|
175
|
-
|
|
176
|
-
|
|
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
|
|
177
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.
|
|
178
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)
|
|
179
325
|
end
|
|
180
326
|
|
|
181
327
|
private
|
|
182
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
|
+
|
|
183
525
|
def from_h(data)
|
|
184
526
|
sources = provenance_array(data["sources"], "_engram.provenance.sources")
|
|
185
527
|
extractor_data = provenance_hash(data["extractor"], "_engram.provenance.extractor")
|
|
@@ -234,24 +576,13 @@ module Engram
|
|
|
234
576
|
|
|
235
577
|
def build_provenance_value(path)
|
|
236
578
|
yield
|
|
237
|
-
rescue ArgumentError, TypeError, KeyError, NoMethodError => error
|
|
579
|
+
rescue ArgumentError, TypeError, KeyError, NoMethodError, EncodingError => error
|
|
238
580
|
raise Engram::Error, "malformed provenance at #{path}: #{error.message}"
|
|
239
581
|
end
|
|
240
582
|
|
|
241
583
|
def symbolize_extractor(data)
|
|
242
584
|
{name: data["name"], provider: data["provider"], model: data["model"]}
|
|
243
585
|
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
586
|
end
|
|
256
587
|
end
|
|
257
588
|
end
|
data/lib/engram/record.rb
CHANGED
|
@@ -8,6 +8,10 @@ module Engram
|
|
|
8
8
|
# `kind` is a memory type (fact / preference / instruction / episodic). The legacy
|
|
9
9
|
# `semantic` kind is normalized to `fact` for compatibility with pre-1.0 records.
|
|
10
10
|
class Record
|
|
11
|
+
STATE_READERS = %i[
|
|
12
|
+
id content scope embedding kind importance metadata created_at last_accessed_at
|
|
13
|
+
].freeze
|
|
14
|
+
|
|
11
15
|
attr_accessor :id, :last_accessed_at
|
|
12
16
|
attr_reader :content, :embedding, :scope, :kind, :importance, :metadata,
|
|
13
17
|
:created_at
|
|
@@ -29,12 +33,15 @@ module Engram
|
|
|
29
33
|
self.class.new(**to_h.merge(attributes))
|
|
30
34
|
end
|
|
31
35
|
|
|
36
|
+
# Return structured supporting-source metadata when this record carries a
|
|
37
|
+
# provenance schema understood by the installed Engram version.
|
|
38
|
+
# Legacy, malformed, and future-schema records remain readable and return nil.
|
|
39
|
+
def provenance
|
|
40
|
+
Engram::Provenance.extract(metadata)
|
|
41
|
+
end
|
|
42
|
+
|
|
32
43
|
def to_h
|
|
33
|
-
{
|
|
34
|
-
id: id, content: content, scope: scope, embedding: embedding, kind: kind,
|
|
35
|
-
importance: importance, metadata: metadata,
|
|
36
|
-
created_at: created_at, last_accessed_at: last_accessed_at
|
|
37
|
-
}
|
|
44
|
+
STATE_READERS.to_h { |reader| [reader, public_send(reader)] }
|
|
38
45
|
end
|
|
39
46
|
end
|
|
40
47
|
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Engram
|
|
4
|
+
module UseCases
|
|
5
|
+
# Count records in a scope by their weakest understood source alignment.
|
|
6
|
+
class GroundingReport
|
|
7
|
+
ALIGNMENT_RANK = Engram::Provenance::ALIGNMENTS.each_with_index.to_h.freeze
|
|
8
|
+
private_constant :ALIGNMENT_RANK
|
|
9
|
+
|
|
10
|
+
def initialize(store:)
|
|
11
|
+
@store = store
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call(scope:)
|
|
15
|
+
counts = {
|
|
16
|
+
exact: 0,
|
|
17
|
+
normalized: 0,
|
|
18
|
+
inferred: 0,
|
|
19
|
+
ungrounded: 0,
|
|
20
|
+
unattributed: 0,
|
|
21
|
+
total: 0
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
@store.all(scope: scope).each do |record|
|
|
25
|
+
provenance = record.provenance
|
|
26
|
+
alignment = provenance && weakest_alignment(provenance)
|
|
27
|
+
counts[alignment || :unattributed] += 1
|
|
28
|
+
counts[:total] += 1
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
counts.freeze
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def weakest_alignment(provenance)
|
|
37
|
+
alignments = provenance.sources.map(&:alignment)
|
|
38
|
+
return unless alignments.all? { |alignment| ALIGNMENT_RANK.key?(alignment) }
|
|
39
|
+
|
|
40
|
+
alignments.max_by { |alignment| ALIGNMENT_RANK.fetch(alignment) }
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|