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.
@@ -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
- 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
17
+ add_prepared(prepare(record), scope: scope)
20
18
  end
21
19
 
22
20
  def update(scope:, id:, record:)
23
- record = prepare(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
- private
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 ? Engram::EmbeddingMetadata.attach(record, embedder: @embedder) : nil
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 per
11
- # candidate that should result in an action).
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<Record> of candidate memories.
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.
@@ -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
- metadata = deep_stringify(metadata || {})
170
- return nil unless metadata.is_a?(Hash)
237
+ extract_for_persistence(metadata)
238
+ rescue Engram::Error
239
+ nil
240
+ end
171
241
 
172
- reserved = metadata[RESERVED_KEY]
173
- return nil unless reserved.is_a?(Hash)
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
- data = reserved[METADATA_KEY]
176
- return nil unless data.is_a?(Hash) && data["version"] == SCHEMA_VERSION
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