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
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Engram
|
|
4
|
+
module Internal
|
|
5
|
+
# Captures reconciliation candidates and verifies that an untrusted custom
|
|
6
|
+
# consolidator treated both the records and their collection as read-only.
|
|
7
|
+
#
|
|
8
|
+
# Core-method binding prevents custom equality, traversal, and serialization
|
|
9
|
+
# hooks from hiding mutations. Arbitrary application metadata leaves are opaque:
|
|
10
|
+
# their identity is retained without executing application behavior.
|
|
11
|
+
class CandidateIntegrity
|
|
12
|
+
class Error < StandardError; end
|
|
13
|
+
class InvalidStateError < Error; end
|
|
14
|
+
class MutationError < Error; end
|
|
15
|
+
|
|
16
|
+
COLLECTION_MUTATION_MESSAGE = "consolidators must not mutate the candidates collection"
|
|
17
|
+
CANDIDATE_MUTATION_MESSAGE = "consolidators must not mutate candidates"
|
|
18
|
+
MAX_NESTING_DEPTH = 100
|
|
19
|
+
|
|
20
|
+
RECORD_STATE_READERS = Engram::Record::STATE_READERS.to_h do |attribute|
|
|
21
|
+
[attribute, Engram::Record.instance_method(attribute)]
|
|
22
|
+
end.freeze
|
|
23
|
+
RECORD_INSTANCE_VARIABLES = Engram::Record::STATE_READERS.map { |attribute| :"@#{attribute}" }.freeze
|
|
24
|
+
|
|
25
|
+
RECORD_FIELD_CLASSES = {
|
|
26
|
+
id: [NilClass, Integer, String],
|
|
27
|
+
content: [String],
|
|
28
|
+
scope: [String],
|
|
29
|
+
kind: [Symbol],
|
|
30
|
+
importance: [Integer, Float],
|
|
31
|
+
created_at: [Time],
|
|
32
|
+
last_accessed_at: [NilClass, Time]
|
|
33
|
+
}.freeze
|
|
34
|
+
|
|
35
|
+
Snapshot = Struct.new(:collection, :collection_state, :records)
|
|
36
|
+
private_constant :Snapshot
|
|
37
|
+
|
|
38
|
+
# Retains the snapshotted object so object-id reuse cannot make a replacement
|
|
39
|
+
# look identical after the original nested value becomes unreachable.
|
|
40
|
+
class IdentityToken
|
|
41
|
+
def initialize(value)
|
|
42
|
+
@value = value
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def ==(other)
|
|
46
|
+
Object.instance_method(:instance_of?).bind_call(other, IdentityToken) &&
|
|
47
|
+
Object.instance_method(:equal?).bind_call(@value, other.value)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
protected
|
|
51
|
+
|
|
52
|
+
attr_reader :value
|
|
53
|
+
end
|
|
54
|
+
private_constant :IdentityToken
|
|
55
|
+
|
|
56
|
+
def snapshot(candidates)
|
|
57
|
+
collection = snapshot_collection(candidates)
|
|
58
|
+
records = collection.map { |candidate| [candidate, snapshot_record(candidate)] }
|
|
59
|
+
Snapshot.new(collection, collection_state(candidates), records)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def verify!(candidates, snapshot)
|
|
63
|
+
verify_collection!(candidates, snapshot.collection, snapshot.collection_state)
|
|
64
|
+
changed = snapshot.records.any? do |candidate, record_snapshot|
|
|
65
|
+
snapshot_record(candidate) != record_snapshot
|
|
66
|
+
end
|
|
67
|
+
raise MutationError, CANDIDATE_MUTATION_MESSAGE if changed
|
|
68
|
+
rescue InvalidStateError => error
|
|
69
|
+
raise MutationError, CANDIDATE_MUTATION_MESSAGE, error.backtrace
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Validates a candidate without dispatching through any of its readers.
|
|
73
|
+
def validate!(candidate)
|
|
74
|
+
snapshot_record(candidate)
|
|
75
|
+
nil
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Returns a structurally detached copy. Plain containers and core mutable
|
|
79
|
+
# values do not share references; opaque application leaves retain identity.
|
|
80
|
+
def detach(candidate)
|
|
81
|
+
validate_detachable_record!(candidate)
|
|
82
|
+
attributes = RECORD_STATE_READERS.to_h do |attribute, reader|
|
|
83
|
+
value = reader.bind_call(candidate)
|
|
84
|
+
validate_record_field!(attribute, value)
|
|
85
|
+
[attribute, attribute.equal?(:metadata) ? duplicate_metadata_value(value) : duplicate_value(value)]
|
|
86
|
+
end
|
|
87
|
+
Engram::Record.new(**attributes)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
private
|
|
91
|
+
|
|
92
|
+
def duplicate_value(value, active = {}.compare_by_identity, depth = 0)
|
|
93
|
+
validate_nesting_depth!(value, depth)
|
|
94
|
+
value_class = plain_class(value)
|
|
95
|
+
return value unless supported_value_class?(value_class)
|
|
96
|
+
|
|
97
|
+
if custom_behavior?(value, value_class) || !Object.instance_method(:instance_variables).bind_call(value).empty?
|
|
98
|
+
raise InvalidStateError, "unsupported candidate value"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
if value_class.equal?(String) || value_class.equal?(Time)
|
|
102
|
+
Object.instance_method(:dup).bind_call(value)
|
|
103
|
+
elsif value_class.equal?(Array)
|
|
104
|
+
with_acyclic_value(value, active) do
|
|
105
|
+
copy = []
|
|
106
|
+
Array.instance_method(:each).bind_call(value) do |element|
|
|
107
|
+
copy << duplicate_value(element, active, depth + 1)
|
|
108
|
+
end
|
|
109
|
+
copy
|
|
110
|
+
end
|
|
111
|
+
elsif value_class.equal?(Hash)
|
|
112
|
+
duplicate_hash(value, active, depth)
|
|
113
|
+
else
|
|
114
|
+
# The remaining supported scalar values are immutable.
|
|
115
|
+
canonical_value(value, active, depth)
|
|
116
|
+
value
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def duplicate_hash(value, active, depth)
|
|
121
|
+
default_proc = Hash.instance_method(:default_proc).bind_call(value)
|
|
122
|
+
raise InvalidStateError, "unsupported candidate value Hash with a default proc" if default_proc
|
|
123
|
+
|
|
124
|
+
with_acyclic_value(value, active) do
|
|
125
|
+
# Hash#transform_values copies the core table and preserves its existing keys
|
|
126
|
+
# without hashing, comparing, serializing, or otherwise dispatching through
|
|
127
|
+
# application key behavior. Values still pass through our structural copier.
|
|
128
|
+
copy = Hash.instance_method(:transform_values).bind_call(value) do |nested_value|
|
|
129
|
+
duplicate_value(nested_value, active, depth + 1)
|
|
130
|
+
end
|
|
131
|
+
copy.default = duplicate_value(Hash.instance_method(:default).bind_call(value), active, depth + 1)
|
|
132
|
+
copy
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def snapshot_collection(candidates)
|
|
137
|
+
unless Object.instance_method(:instance_of?).bind_call(candidates, Array) &&
|
|
138
|
+
!custom_behavior?(candidates, Array) &&
|
|
139
|
+
Object.instance_method(:instance_variables).bind_call(candidates).empty?
|
|
140
|
+
raise InvalidStateError, "candidates must be a plain Array"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
snapshot = []
|
|
144
|
+
Array.instance_method(:each).bind_call(candidates) { |candidate| snapshot << candidate }
|
|
145
|
+
snapshot
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def collection_state(candidates)
|
|
149
|
+
[identity(candidates), frozen?(candidates)]
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def verify_collection!(candidates, snapshot, original_state)
|
|
153
|
+
unless Object.instance_method(:instance_of?).bind_call(candidates, Array) &&
|
|
154
|
+
!custom_behavior?(candidates, Array) &&
|
|
155
|
+
Object.instance_method(:instance_variables).bind_call(candidates).empty? &&
|
|
156
|
+
collection_state(candidates) == original_state
|
|
157
|
+
raise MutationError, COLLECTION_MUTATION_MESSAGE
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
current_size = Array.instance_method(:length).bind_call(candidates)
|
|
161
|
+
original_size = Array.instance_method(:length).bind_call(snapshot)
|
|
162
|
+
unchanged = current_size == original_size && original_size.times.all? do |index|
|
|
163
|
+
current = Array.instance_method(:[]).bind_call(candidates, index)
|
|
164
|
+
original = Array.instance_method(:[]).bind_call(snapshot, index)
|
|
165
|
+
Object.instance_method(:equal?).bind_call(current, original)
|
|
166
|
+
end
|
|
167
|
+
return if unchanged
|
|
168
|
+
|
|
169
|
+
raise MutationError, COLLECTION_MUTATION_MESSAGE
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def snapshot_record(candidate)
|
|
173
|
+
unless plain_record?(candidate)
|
|
174
|
+
raise InvalidStateError, "candidate must be a plain Engram::Record"
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
instance_variables = Object.instance_method(:instance_variables).bind_call(candidate)
|
|
178
|
+
unless instance_variables.sort == RECORD_INSTANCE_VARIABLES.sort
|
|
179
|
+
unexpected = instance_variables - RECORD_INSTANCE_VARIABLES
|
|
180
|
+
missing = RECORD_INSTANCE_VARIABLES - instance_variables
|
|
181
|
+
details = []
|
|
182
|
+
details << "unexpected: #{unexpected.map(&:inspect).join(", ")}" unless unexpected.empty?
|
|
183
|
+
details << "missing: #{missing.map(&:inspect).join(", ")}" unless missing.empty?
|
|
184
|
+
raise InvalidStateError,
|
|
185
|
+
"candidate Record must have exactly the expected instance variables (#{details.join("; ")})"
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
[frozen?(candidate), RECORD_STATE_READERS.to_h do |attribute, reader|
|
|
189
|
+
value = reader.bind_call(candidate)
|
|
190
|
+
validate_record_field!(attribute, value)
|
|
191
|
+
canonical = attribute.equal?(:metadata) ? canonical_metadata_value(value) : canonical_value(value)
|
|
192
|
+
[attribute, canonical]
|
|
193
|
+
end, provenance_evidence(RECORD_STATE_READERS.fetch(:metadata).bind_call(candidate))]
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Provenance is security evidence rather than arbitrary application metadata.
|
|
197
|
+
# Parse it independently so an opaque Hash subclass cannot hide changes to the
|
|
198
|
+
# schema fields used for persistence and destructive authorization. Invalid and
|
|
199
|
+
# future schemas remain opaque-compatible here: write paths reject them, while a
|
|
200
|
+
# noop observation may continue without CandidateIntegrity rejecting it early.
|
|
201
|
+
def provenance_evidence(metadata)
|
|
202
|
+
provenance = Engram::Provenance.canonical_payload_for_persistence(metadata)
|
|
203
|
+
provenance ? [:valid, provenance] : [:absent]
|
|
204
|
+
rescue Engram::Error
|
|
205
|
+
[:invalid]
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def validate_record_field!(attribute, value)
|
|
209
|
+
if attribute.equal?(:metadata)
|
|
210
|
+
unless core_container?(value, Hash)
|
|
211
|
+
raise InvalidStateError, "candidate metadata must be a Hash"
|
|
212
|
+
end
|
|
213
|
+
return
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
if attribute.equal?(:embedding)
|
|
217
|
+
validate_embedding!(value)
|
|
218
|
+
return
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
value_class = plain_class(value)
|
|
222
|
+
allowed = RECORD_FIELD_CLASSES.fetch(attribute)
|
|
223
|
+
unless allowed.any? { |klass| value_class.equal?(klass) }
|
|
224
|
+
raise InvalidStateError, "candidate #{attribute} has an unsupported value"
|
|
225
|
+
end
|
|
226
|
+
canonical_value(value)
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def validate_embedding!(value)
|
|
230
|
+
return if plain_class(value).equal?(NilClass)
|
|
231
|
+
unless plain_class(value).equal?(Array)
|
|
232
|
+
raise InvalidStateError, "candidate embedding must be a plain Array of numbers or nil"
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
canonical_value(value)
|
|
236
|
+
Array.instance_method(:each).bind_call(value) do |element|
|
|
237
|
+
element_class = plain_class(element)
|
|
238
|
+
unless element_class.equal?(Integer) || element_class.equal?(Float)
|
|
239
|
+
raise InvalidStateError, "candidate embedding must be a plain Array of numbers or nil"
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Metadata containers are structural even when subclassed: bound core traversal
|
|
245
|
+
# ignores their behavior. Only non-container application leaves remain opaque.
|
|
246
|
+
def canonical_metadata_value(value, active = {}.compare_by_identity, depth = 0)
|
|
247
|
+
validate_nesting_depth!(value, depth)
|
|
248
|
+
if core_container?(value, Hash)
|
|
249
|
+
default_proc = Hash.instance_method(:default_proc).bind_call(value)
|
|
250
|
+
raise InvalidStateError, "unsupported candidate value Hash with a default proc" if default_proc
|
|
251
|
+
|
|
252
|
+
with_acyclic_value(value, active) do
|
|
253
|
+
entries = []
|
|
254
|
+
Engram::Internal::CoreHash.each_pair(value) do |key, nested_value|
|
|
255
|
+
entries << [
|
|
256
|
+
canonical_metadata_value(key, active, depth + 1),
|
|
257
|
+
canonical_metadata_value(nested_value, active, depth + 1)
|
|
258
|
+
]
|
|
259
|
+
end
|
|
260
|
+
default = canonical_metadata_value(Hash.instance_method(:default).bind_call(value), active, depth + 1)
|
|
261
|
+
[:metadata_hash, identity(value), frozen?(value),
|
|
262
|
+
Hash.instance_method(:compare_by_identity?).bind_call(value), default, entries]
|
|
263
|
+
end
|
|
264
|
+
elsif core_container?(value, Array)
|
|
265
|
+
with_acyclic_value(value, active) do
|
|
266
|
+
elements = []
|
|
267
|
+
Array.instance_method(:each).bind_call(value) do |element|
|
|
268
|
+
elements << canonical_metadata_value(element, active, depth + 1)
|
|
269
|
+
end
|
|
270
|
+
[:metadata_array, identity(value), frozen?(value), elements]
|
|
271
|
+
end
|
|
272
|
+
else
|
|
273
|
+
value_class = plain_class(value)
|
|
274
|
+
if !supported_value_class?(value_class) || custom_behavior?(value, value_class) ||
|
|
275
|
+
!Object.instance_method(:instance_variables).bind_call(value).empty?
|
|
276
|
+
[:opaque, identity(value)]
|
|
277
|
+
else
|
|
278
|
+
canonical_value(value, active, depth)
|
|
279
|
+
end
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def duplicate_metadata_value(value, active = {}.compare_by_identity, depth = 0)
|
|
284
|
+
validate_nesting_depth!(value, depth)
|
|
285
|
+
if core_container?(value, Hash)
|
|
286
|
+
default_proc = Hash.instance_method(:default_proc).bind_call(value)
|
|
287
|
+
raise InvalidStateError, "unsupported candidate value Hash with a default proc" if default_proc
|
|
288
|
+
|
|
289
|
+
with_acyclic_value(value, active) do
|
|
290
|
+
copy = Hash.instance_method(:transform_values).bind_call(value) do |nested_value|
|
|
291
|
+
duplicate_metadata_value(nested_value, active, depth + 1)
|
|
292
|
+
end
|
|
293
|
+
copy.default = duplicate_metadata_value(
|
|
294
|
+
Hash.instance_method(:default).bind_call(value), active, depth + 1
|
|
295
|
+
)
|
|
296
|
+
copy
|
|
297
|
+
end
|
|
298
|
+
elsif core_container?(value, Array)
|
|
299
|
+
with_acyclic_value(value, active) do
|
|
300
|
+
copy = []
|
|
301
|
+
Array.instance_method(:each).bind_call(value) do |element|
|
|
302
|
+
copy << duplicate_metadata_value(element, active, depth + 1)
|
|
303
|
+
end
|
|
304
|
+
copy
|
|
305
|
+
end
|
|
306
|
+
else
|
|
307
|
+
value_class = plain_class(value)
|
|
308
|
+
if !supported_value_class?(value_class) || custom_behavior?(value, value_class) ||
|
|
309
|
+
!Object.instance_method(:instance_variables).bind_call(value).empty?
|
|
310
|
+
value
|
|
311
|
+
else
|
|
312
|
+
duplicate_value(value, active, depth)
|
|
313
|
+
end
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def core_container?(value, klass)
|
|
318
|
+
Object.instance_method(:is_a?).bind_call(value, klass)
|
|
319
|
+
rescue TypeError
|
|
320
|
+
false
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
def canonical_value(value, active = {}.compare_by_identity, depth = 0)
|
|
324
|
+
validate_nesting_depth!(value, depth)
|
|
325
|
+
value_class = plain_class(value)
|
|
326
|
+
return [:opaque, identity(value)] unless supported_value_class?(value_class)
|
|
327
|
+
|
|
328
|
+
if custom_behavior?(value, value_class)
|
|
329
|
+
raise InvalidStateError, "unsupported candidate value with custom behavior"
|
|
330
|
+
end
|
|
331
|
+
unless Object.instance_method(:instance_variables).bind_call(value).empty?
|
|
332
|
+
raise InvalidStateError, "unsupported candidate value with custom state"
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
if value_class.equal?(NilClass)
|
|
336
|
+
[:nil]
|
|
337
|
+
elsif value_class.equal?(TrueClass)
|
|
338
|
+
[:boolean, true]
|
|
339
|
+
elsif value_class.equal?(FalseClass)
|
|
340
|
+
[:boolean, false]
|
|
341
|
+
elsif value_class.equal?(String)
|
|
342
|
+
encoding = String.instance_method(:encoding).bind_call(value)
|
|
343
|
+
bytes = String.instance_method(:b).bind_call(value)
|
|
344
|
+
[:string, identity(value), frozen?(value), encoding, bytes]
|
|
345
|
+
elsif value_class.equal?(Symbol)
|
|
346
|
+
[:symbol, value]
|
|
347
|
+
elsif value_class.equal?(Integer)
|
|
348
|
+
[:integer, value.to_s]
|
|
349
|
+
elsif value_class.equal?(Float)
|
|
350
|
+
[:float, [value].pack("G")]
|
|
351
|
+
elsif value_class.equal?(Time)
|
|
352
|
+
canonical_time(value)
|
|
353
|
+
elsif value_class.equal?(Array)
|
|
354
|
+
canonical_array(value, active, depth)
|
|
355
|
+
elsif value_class.equal?(Hash)
|
|
356
|
+
canonical_hash(value, active, depth)
|
|
357
|
+
else
|
|
358
|
+
raise InvalidStateError, "unsupported candidate value"
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def plain_record?(candidate)
|
|
363
|
+
Object.instance_method(:instance_of?).bind_call(candidate, Engram::Record) &&
|
|
364
|
+
!custom_behavior?(candidate, Engram::Record)
|
|
365
|
+
rescue TypeError
|
|
366
|
+
false
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
def validate_detachable_record!(candidate)
|
|
370
|
+
is_record = Object.instance_method(:is_a?).bind_call(candidate, Engram::Record)
|
|
371
|
+
raise InvalidStateError, "candidate must be an Engram::Record" unless is_record
|
|
372
|
+
|
|
373
|
+
instance_variables = Object.instance_method(:instance_variables).bind_call(candidate)
|
|
374
|
+
unless instance_variables.sort == RECORD_INSTANCE_VARIABLES.sort
|
|
375
|
+
raise InvalidStateError, "candidate Record must have exactly the expected instance variables"
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
if Object.instance_method(:instance_of?).bind_call(candidate, Engram::Record) && !plain_record?(candidate)
|
|
379
|
+
raise InvalidStateError, "candidate must be a plain Engram::Record"
|
|
380
|
+
end
|
|
381
|
+
rescue TypeError
|
|
382
|
+
raise InvalidStateError, "candidate must be an Engram::Record"
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
def plain_class(value)
|
|
386
|
+
Object.instance_method(:class).bind_call(value)
|
|
387
|
+
rescue TypeError
|
|
388
|
+
nil
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
def supported_value_class?(value_class)
|
|
392
|
+
[NilClass, TrueClass, FalseClass, String, Symbol, Integer, Float, Time, Array, Hash].any? do |klass|
|
|
393
|
+
value_class.equal?(klass)
|
|
394
|
+
end
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
def custom_behavior?(value, value_class)
|
|
398
|
+
singleton_class = begin
|
|
399
|
+
Object.instance_method(:singleton_class).bind_call(value)
|
|
400
|
+
rescue TypeError
|
|
401
|
+
return false
|
|
402
|
+
end
|
|
403
|
+
return false if singleton_class.equal?(value_class)
|
|
404
|
+
|
|
405
|
+
method_visibilities = %i[
|
|
406
|
+
public_instance_methods protected_instance_methods private_instance_methods
|
|
407
|
+
]
|
|
408
|
+
return true if method_visibilities.any? do |visibility|
|
|
409
|
+
Module.instance_method(visibility).bind_call(singleton_class, false).any?
|
|
410
|
+
end
|
|
411
|
+
|
|
412
|
+
singleton_ancestors = Module.instance_method(:ancestors).bind_call(singleton_class)
|
|
413
|
+
class_ancestors = Module.instance_method(:ancestors).bind_call(value_class)
|
|
414
|
+
return true unless singleton_ancestors.length == class_ancestors.length + 1 &&
|
|
415
|
+
class_ancestors.each_with_index.all? { |ancestor, index| singleton_ancestors[index + 1].equal?(ancestor) }
|
|
416
|
+
|
|
417
|
+
method_visibilities.any? do |visibility|
|
|
418
|
+
singleton_methods = Module.instance_method(visibility).bind_call(singleton_class, true)
|
|
419
|
+
class_methods = Module.instance_method(visibility).bind_call(value_class, true)
|
|
420
|
+
singleton_methods.length != class_methods.length ||
|
|
421
|
+
singleton_methods.any? { |method_name| !class_methods.include?(method_name) }
|
|
422
|
+
end
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
def canonical_time(value)
|
|
426
|
+
exact_value = Time.instance_method(:to_r).bind_call(value)
|
|
427
|
+
components = Time.instance_method(:to_a).bind_call(value)
|
|
428
|
+
[:time,
|
|
429
|
+
identity(value),
|
|
430
|
+
frozen?(value),
|
|
431
|
+
Time.instance_method(:to_i).bind_call(value),
|
|
432
|
+
Time.instance_method(:nsec).bind_call(value),
|
|
433
|
+
Rational.instance_method(:numerator).bind_call(exact_value),
|
|
434
|
+
Rational.instance_method(:denominator).bind_call(exact_value),
|
|
435
|
+
Time.instance_method(:utc_offset).bind_call(value),
|
|
436
|
+
Time.instance_method(:utc?).bind_call(value),
|
|
437
|
+
components.map { |component| canonical_time_component(component) }]
|
|
438
|
+
end
|
|
439
|
+
|
|
440
|
+
def canonical_time_component(value)
|
|
441
|
+
value_class = Object.instance_method(:class).bind_call(value)
|
|
442
|
+
if value_class.equal?(NilClass)
|
|
443
|
+
[:nil]
|
|
444
|
+
elsif value_class.equal?(TrueClass) || value_class.equal?(FalseClass)
|
|
445
|
+
[:boolean, value]
|
|
446
|
+
elsif value_class.equal?(String)
|
|
447
|
+
[:string, String.instance_method(:encoding).bind_call(value), String.instance_method(:b).bind_call(value)]
|
|
448
|
+
elsif value_class.equal?(Integer)
|
|
449
|
+
[:integer, value.to_s]
|
|
450
|
+
else
|
|
451
|
+
raise InvalidStateError, "unsupported Time component #{value_class}"
|
|
452
|
+
end
|
|
453
|
+
end
|
|
454
|
+
|
|
455
|
+
def canonical_array(value, active, depth)
|
|
456
|
+
with_acyclic_value(value, active) do
|
|
457
|
+
elements = []
|
|
458
|
+
Array.instance_method(:each).bind_call(value) do |element|
|
|
459
|
+
elements << canonical_value(element, active, depth + 1)
|
|
460
|
+
end
|
|
461
|
+
[:array, identity(value), frozen?(value), elements]
|
|
462
|
+
end
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
def canonical_hash(value, active, depth)
|
|
466
|
+
default_proc = Hash.instance_method(:default_proc).bind_call(value)
|
|
467
|
+
raise InvalidStateError, "unsupported candidate value Hash with a default proc" if default_proc
|
|
468
|
+
|
|
469
|
+
with_acyclic_value(value, active) do
|
|
470
|
+
entries = []
|
|
471
|
+
Engram::Internal::CoreHash.each_pair(value) do |key, nested_value|
|
|
472
|
+
entries << [
|
|
473
|
+
canonical_value(key, active, depth + 1),
|
|
474
|
+
canonical_value(nested_value, active, depth + 1)
|
|
475
|
+
]
|
|
476
|
+
end
|
|
477
|
+
default = Hash.instance_method(:default).bind_call(value)
|
|
478
|
+
compare_by_identity = Hash.instance_method(:compare_by_identity?).bind_call(value)
|
|
479
|
+
[:hash, identity(value), frozen?(value), compare_by_identity,
|
|
480
|
+
canonical_value(default, active, depth + 1), entries]
|
|
481
|
+
end
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
def frozen?(value)
|
|
485
|
+
Object.instance_method(:frozen?).bind_call(value)
|
|
486
|
+
end
|
|
487
|
+
|
|
488
|
+
def identity(value)
|
|
489
|
+
IdentityToken.new(value)
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
def validate_nesting_depth!(value, depth)
|
|
493
|
+
return if depth <= MAX_NESTING_DEPTH
|
|
494
|
+
return unless core_container?(value, Array) || core_container?(value, Hash)
|
|
495
|
+
|
|
496
|
+
raise InvalidStateError,
|
|
497
|
+
"unsupported candidate value: nesting exceeds maximum depth of #{MAX_NESTING_DEPTH}"
|
|
498
|
+
end
|
|
499
|
+
|
|
500
|
+
def with_acyclic_value(value, active)
|
|
501
|
+
raise InvalidStateError, "unsupported cyclic candidate value" if active.key?(value)
|
|
502
|
+
|
|
503
|
+
active[value] = true
|
|
504
|
+
yield
|
|
505
|
+
ensure
|
|
506
|
+
active.delete(value)
|
|
507
|
+
end
|
|
508
|
+
end
|
|
509
|
+
end
|
|
510
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Engram
|
|
4
|
+
module Internal
|
|
5
|
+
# Iterates a Hash's stored (key, value) pairs without dispatching through the
|
|
6
|
+
# hash's own overridden methods and without provoking per-key #hash/#eql?.
|
|
7
|
+
#
|
|
8
|
+
# Bound Hash#each_pair was chosen elsewhere to bypass a Hash subclass's
|
|
9
|
+
# overridden traversal. It is not enough on its own: on Ruby 3.4 a hash left in
|
|
10
|
+
# a delete-then-insert collision state can invoke a *key's* #eql? during plain
|
|
11
|
+
# iteration (Hash#each_pair / #to_a), which would let a malicious String-subclass
|
|
12
|
+
# key run application code inside code that must stay behavior-free. Bound #keys
|
|
13
|
+
# and #values return the stored key and value objects in matching insertion order
|
|
14
|
+
# without comparing keys, so zipping them by position reconstructs every pair
|
|
15
|
+
# with no application dispatch.
|
|
16
|
+
module CoreHash
|
|
17
|
+
KEYS = ::Hash.instance_method(:keys)
|
|
18
|
+
VALUES = ::Hash.instance_method(:values)
|
|
19
|
+
private_constant :KEYS, :VALUES
|
|
20
|
+
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
def each_pair(hash)
|
|
24
|
+
keys = KEYS.bind_call(hash)
|
|
25
|
+
values = VALUES.bind_call(hash)
|
|
26
|
+
index = 0
|
|
27
|
+
count = keys.length
|
|
28
|
+
while index < count
|
|
29
|
+
yield keys[index], values[index]
|
|
30
|
+
index += 1
|
|
31
|
+
end
|
|
32
|
+
hash
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Engram
|
|
4
|
+
module Internal
|
|
5
|
+
# Compares Record scopes without dispatching through application-defined readers
|
|
6
|
+
# or equality methods. Keep orchestration preflight and persistence enforcement on
|
|
7
|
+
# the same fail-closed rules.
|
|
8
|
+
module Scope
|
|
9
|
+
RECORD_SCOPE_READER = Engram::Record.instance_method(:scope)
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def record_matches?(record, expected_scope)
|
|
14
|
+
same?(RECORD_SCOPE_READER.bind_call(record), expected_scope)
|
|
15
|
+
rescue TypeError
|
|
16
|
+
false
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def same?(actual_scope, expected_scope)
|
|
20
|
+
actual_class = Object.instance_method(:class).bind_call(actual_scope)
|
|
21
|
+
expected_class = Object.instance_method(:class).bind_call(expected_scope)
|
|
22
|
+
return true if actual_class.equal?(NilClass) && expected_class.equal?(NilClass)
|
|
23
|
+
return false unless actual_class.equal?(String) && expected_class.equal?(String)
|
|
24
|
+
|
|
25
|
+
String.instance_method(:==).bind_call(actual_scope, expected_scope)
|
|
26
|
+
rescue TypeError
|
|
27
|
+
false
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
data/lib/engram/memory.rb
CHANGED
|
@@ -104,6 +104,21 @@ module Engram
|
|
|
104
104
|
.call(scope: scope, older_than: older_than, min_importance: min_importance)
|
|
105
105
|
end
|
|
106
106
|
|
|
107
|
+
# Return the memories in this scope whose provenance references the exact host
|
|
108
|
+
# source. `source_id` and `source_type` must each be non-blank Strings and are
|
|
109
|
+
# matched exactly, without normalization. Source IDs are references, not an
|
|
110
|
+
# authorization boundary; the lookup stays bound to this scope.
|
|
111
|
+
def memories_from_source(source_id:, source_type:)
|
|
112
|
+
UseCases::SourceImpact.new(store: @store)
|
|
113
|
+
.call(scope: scope, source_id: source_id, source_type: source_type)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Count memories by their weakest provenance source alignment. Records whose
|
|
117
|
+
# provenance is absent or not understood are counted as unattributed.
|
|
118
|
+
def grounding_report
|
|
119
|
+
UseCases::GroundingReport.new(store: @store).call(scope: scope)
|
|
120
|
+
end
|
|
121
|
+
|
|
107
122
|
private
|
|
108
123
|
|
|
109
124
|
def persist(record)
|