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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8a38dfd456e4e89f37a9d4d8949a5bb77b9736d95621e9172ba1db72fb3a5771
4
- data.tar.gz: 0b537ee13bea0313fd770c529024af45e9ace8fb84f7e49621c2dc7b9d6b888c
3
+ metadata.gz: c623f8198a9f71905e6e0c904b6cf60d5d6a0734b1e8fdaf877611a815f2a919
4
+ data.tar.gz: aca21e3da50239a5d02822306524adc6647883ef607af57747b05ce7ecb78827
5
5
  SHA512:
6
- metadata.gz: 768d5d18a9ce04f60db58957127df76fbdc30482df4976d7d04b082af005850ba821ba45a35417c33946668af60331e869cf930e6239409ba10168a453f58665
7
- data.tar.gz: 7ec7997ba40947deede93b6e970a549bf6caae32eed79f0e4c3922ebecbf05bc1545b5565afc521c9dab6c0f95b27898b3ae9bd9f3f57d9935a48d18e9211322
6
+ metadata.gz: 55f5e8335a86ec28cd28b17af162883fcc7c58dc56b903fc18b924c4278b593d9c8945cbf19cb28649a4c66de57ff38e6af46f3c50b15a6a96a8d729b90fc6ba
7
+ data.tar.gz: 519081acdc6f1b284df51bc664d274ca31befbc2d7a002ef9d62fee2f31c165f64c2896ab5f2dddca771b02c2052dcb630ef785e440776931d53d7f994de6e47
data/CHANGELOG.md CHANGED
@@ -5,6 +5,62 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.6.0] - 2026-07-24
9
+
10
+ ### Added
11
+ - `Engram::Memory#grounding_report` (and `Engram::UseCases::GroundingReport`) returns frozen,
12
+ scope-bound record counts by weakest source alignment, with legacy or unrecognized provenance
13
+ counted as unattributed and no record or source text exposed.
14
+ - Add an immutable host-supplied source-text contract that validates provenance source identity
15
+ and Unicode-codepoint span bounds, and resolves authorized supporting text without retaining
16
+ source transcripts.
17
+ - Optional `Engram::Extraction` results let custom extractors attach versioned source
18
+ provenance while remaining compatible with plain `Engram::Record` results.
19
+ - `Engram::Record#provenance` exposes understood supporting source IDs, alignments, and spans
20
+ on recalled records while preserving tolerant reads for legacy and future schemas.
21
+ - `Engram::Memory#memories_from_source` (and `Engram::UseCases::SourceImpact`) return the
22
+ records in a scope whose provenance references an exact host source. `source_id` and
23
+ `source_type` must each be a non-blank String and are matched exactly without trimming or
24
+ normalization. The lookup is scope-bound and returns only records, never source
25
+ text; source IDs are references, not an authorization boundary. Legacy, malformed, and
26
+ future-schema provenance do not match.
27
+
28
+ ### Changed
29
+ - Persistence accepts records without provenance, rejects structurally ungrounded provenance
30
+ by default (configurable only with the exact boolean `allow_ungrounded: true`), and fails closed on malformed or
31
+ unknown future provenance during writes while keeping reads tolerant. Provenance validation
32
+ does not verify source text, and source IDs are references rather than authorization
33
+ boundaries. The built-in LLM extractor does not emit grounded provenance.
34
+ - Existing `before_persist` hooks may continue to transform record content or embeddings, but
35
+ may neither add, remove, nor alter provenance; this restriction includes legacy records with
36
+ no provenance.
37
+ - Extractors must return an `Array`; each result may be a plain `Engram::Record` or an
38
+ `Engram::Extraction` carrying provenance.
39
+ - Custom consolidator decisions must reference the actual same-scope `Engram::Record` instance
40
+ supplied for reconciliation and must treat both the candidates array and records as read-only.
41
+ Engram now fails closed on collection replacement/reordering/iteration overrides and on
42
+ security-relevant nested value, identity/alias/topology, frozen-state, custom-behavior, or
43
+ hidden-state changes before destructive authorization. Arbitrary application metadata values
44
+ remain opaque and preserve their identity without invoking equality or serialization behavior;
45
+ provenance is independently parsed and integrity-protected.
46
+ Substituted, modified, missing, malformed, or cross-scope candidates fail closed.
47
+ `Observe` preflights the complete decision batch, including persistence policy and hook
48
+ transformations, before beginning store mutations.
49
+
50
+ ### Fixed
51
+ - Provenance canonicalization now iterates untrusted metadata through core `Hash#keys`/`#values`
52
+ by position instead of `Hash#each_pair`. On Ruby 3.4 a caller-supplied hash left in a
53
+ delete-then-insert collision state could make `Hash#each_pair` invoke a key's `#eql?` mid-iteration,
54
+ letting a hostile `String`-subclass key run application code inside the behavior-free canonicalizer.
55
+ The adversarial hostile-key suite is now deterministic across Ruby 3.2–3.4.
56
+ - Observation rejects extractor candidates with caller-supplied IDs, and `InMemoryStore#add`
57
+ always allocates a fresh ID like the pgvector adapter, preventing same- or cross-scope record
58
+ replacement through add semantics.
59
+ - `forget` now separates destructive provenance authorization from write-content filtering and
60
+ redaction, so secret or transient memories can be deleted. Custom policies may implement
61
+ `allow_destructive?` with a strict boolean return; write-only policies no longer authorize
62
+ deletion through their `call` result.
63
+
8
64
  ## [0.5.0] - 2026-07-17
9
65
 
10
66
  ### Added
data/README.md CHANGED
@@ -52,7 +52,7 @@ chat.ask("Why am I being rate limited?")
52
52
 
53
53
  ## Feature overview
54
54
 
55
- - Zero-dependency pure Ruby core with in-memory defaults for tests and local development.
55
+ - Pure Ruby core with zero runtime dependencies and in-memory defaults for tests and local development.
56
56
  - Rails `has_memory` macro, install generator, and background `observe_later` job.
57
57
  - Postgres + pgvector storage through an optional ActiveRecord/neighbor adapter.
58
58
  - RubyLLM embedder and completion adapters for provider-backed embeddings and extraction.
@@ -74,7 +74,7 @@ explicitly version-gated:
74
74
  - Core types and facade: `Engram::Memory`, `Engram::Record`, `Engram::Decision`, `Engram::PersistencePolicy`, and `Engram.with_memory`.
75
75
  - Store and adapter ports: `Engram::Ports::MemoryStore`, `Engram::Ports::Embedder`, `Engram::Ports::Completion`.
76
76
  - Rails integration points: `has_memory`, `Memory#observe_later`, and generator outputs under `engram:` rake tasks.
77
- - Lifecycle methods in `Engram::Memory`: `add`, `recall`, `inject_into`, `observe`, `observe_later`, `forget_stale`, and `rebuild_embeddings`.
77
+ - Lifecycle methods in `Engram::Memory`: `add`, `recall`, `inject_into`, `observe`, `observe_later`, `forget_stale`, `rebuild_embeddings`, and `memories_from_source`.
78
78
  - RubyLLM adapter contract points and evaluator entrypoints (`rake eval`, `rake eval:real`).
79
79
 
80
80
  ### Backward-compatibility commitments (pre-1.0)
@@ -103,7 +103,7 @@ Use `CHANGELOG.md` as the authoritative source for breaking/compatibility change
103
103
  gem "engram"
104
104
  ```
105
105
 
106
- The core has **zero runtime dependencies**. Optional adapters need host-app dependencies:
106
+ The core has zero runtime dependencies and does not require Rails, ActiveRecord, or a database. Optional adapters need host-app dependencies:
107
107
 
108
108
  - `Engram::Adapters::PgvectorStore` → ActiveRecord + `neighbor` + Postgres/pgvector
109
109
  - `Engram::Adapters::RubyLLMEmbedder` and `Engram::Adapters::RubyLLMCompletion` → `ruby_llm`
@@ -291,6 +291,25 @@ memory.observe([
291
291
  # extracts "User is on the Pro plan", and if a "Free plan" memory exists, updates it
292
292
  ```
293
293
 
294
+ ### Custom consolidator candidate contract
295
+
296
+ `reconcile_all(candidates:, scope:)` receives a plain `Array` of plain `Engram::Record`
297
+ instances. A custom consolidator may inspect candidates and return decisions that reference
298
+ those exact instances, but it must not replace, append, remove, or reorder collection entries;
299
+ override collection iteration; add collection or record state or behavior; or mutate the
300
+ records. Engram verifies record and collection integrity before it authorizes any add, update,
301
+ or deletion and fails closed on a violation.
302
+
303
+ Plain arrays and hashes in candidate state are detached without serializing their leaves.
304
+ Core scalar/container state is integrity-protected without dispatching overridden traversal or
305
+ equality. Arbitrary application metadata values (including value objects, procs, and IO) remain
306
+ opaque, retain their identity, and are never compared or serialized by the integrity check.
307
+ Their internal mutable state is consequently outside that check; provenance remains parsed and
308
+ validated independently before any authorization. Containers must be acyclic, hashes cannot
309
+ have default procs, and behavior-bearing core values remain unsupported.
310
+ Extracted candidates must also have a nil `id`: IDs are store-owned and are allocated only
311
+ when an add is persisted.
312
+
294
313
  ## Memory kinds and persistence policy
295
314
 
296
315
  Every memory has a normalized `kind`:
@@ -329,6 +348,107 @@ Engram.configure do |config|
329
348
  end
330
349
  ```
331
350
 
351
+ Write-content filtering and transformation are not deletion authorization. `forget` still
352
+ validates scope, target existence, candidate integrity, and provenance, but it does not run
353
+ `before_persist` or the policy's `call` method; this allows secret, transient, and redacted
354
+ memories to be removed. A custom policy can additionally control destructive decisions by
355
+ implementing `allow_destructive?(record)` and returning exactly `true` or `false`. Policies
356
+ that only implement `call` affect writes but do not prevent deletion. The built-in policy uses
357
+ this separate hook to retain its ungrounded-provenance protection.
358
+
359
+ ### Extraction provenance
360
+
361
+ Custom extractors remain compatible when they return an array of plain `Engram::Record`
362
+ values. They may instead return `Engram::Extraction` values to attach optional structured
363
+ `Engram::Provenance` to a candidate; arrays may contain either type. Records with no
364
+ provenance remain accepted.
365
+
366
+ Recalled records expose understood provenance directly, without loading source content:
367
+
368
+ ```ruby
369
+ record = memory.recall("where did this preference come from?").first
370
+
371
+ record.provenance&.sources&.each do |source|
372
+ puts [source.source_id, source.alignment, source.spans.map(&:to_h)].inspect
373
+ end
374
+ ```
375
+
376
+ When the host application has authorized and loaded the referenced message, it can validate
377
+ the source identity and span bounds before presenting supporting text:
378
+
379
+ ```ruby
380
+ source = record.provenance.sources.first
381
+ source_text = Engram::Provenance::SourceText.new(
382
+ source_id: source.source_id,
383
+ source_type: source.source_type,
384
+ message_index: source.message_index,
385
+ role: source.role,
386
+ text: authorized_message_body
387
+ )
388
+
389
+ source.validate_source_text!(source_text)
390
+ source.supporting_text(source_text) # text for each zero-based, end-exclusive codepoint span
391
+ ```
392
+
393
+ Validation fails when any source identity field differs or a span exceeds that one message.
394
+ Offsets count Unicode codepoints, not bytes or user-perceived grapheme clusters.
395
+ `exact` and `normalized` remain extractor assertions: this API verifies identity and bounds but
396
+ does not claim that supporting text semantically entails the memory.
397
+
398
+ `Record#provenance` returns `nil` for legacy records and for malformed or future schemas so
399
+ reads remain compatible. Treat the returned source IDs and spans as untrusted references.
400
+ They do not prove source access or authorize retrieving source content; applications must
401
+ enforce the record's scope and their own source authorization before resolving them.
402
+
403
+ By default, the persistence policy rejects provenance containing a source marked
404
+ `ungrounded`. Applications that intentionally accept it can opt out:
405
+
406
+ ```ruby
407
+ Engram.configure do |config|
408
+ config.persistence_policy = Engram::PersistencePolicy.new(allow_ungrounded: true)
409
+ end
410
+ ```
411
+
412
+ Persistence validation is structural: Engram does not retain source text or verify that an
413
+ alignment is truthful. Source IDs are host-owned references, not authorization boundaries.
414
+ The built-in `Engram::Extractors::LLMExtractor` continues to return plain records and does not
415
+ emit grounded provenance. Reads tolerate malformed or future provenance metadata, but writes
416
+ fail closed when provenance is malformed or uses an unknown future schema version; upgrade an
417
+ older writer before rewriting such records.
418
+
419
+ ### Source impact lookup
420
+
421
+ To find which memories in a scope a host source produced, look them up by exact source
422
+ reference:
423
+
424
+ ```ruby
425
+ current_user.memory.memories_from_source(
426
+ source_id: "conversation:42",
427
+ source_type: "conversation"
428
+ )
429
+ ```
430
+
431
+ `source_id` and `source_type` must each be a non-blank String and are matched exactly, with no
432
+ trimming or normalization. The lookup is bound to the scope and returns an `Array` of
433
+ `Engram::Record` values (empty when nothing matches) — never source text. Result order follows
434
+ the store adapter's enumeration and is not otherwise guaranteed. Source IDs are references,
435
+ not an authorization boundary, so continue to enforce scope and your own source authorization.
436
+ Legacy, malformed, and future-schema provenance do not match.
437
+
438
+ ### Grounding report
439
+
440
+ To measure provenance grounding coverage without exposing record or source text, request the
441
+ scope-bound report:
442
+
443
+ ```ruby
444
+ current_user.memory.grounding_report
445
+ # => {exact: 12, normalized: 3, inferred: 2, ungrounded: 1, unattributed: 4, total: 22}
446
+ ```
447
+
448
+ Each record is counted once by its weakest source alignment (`exact`, `normalized`, `inferred`,
449
+ then `ungrounded`). Records with no understood provenance, including legacy, malformed, and
450
+ future-schema provenance, count as `unattributed`. The returned counts-only Hash is frozen.
451
+
332
452
  ## Prompt-injection and memory-injection safety
333
453
 
334
454
  Injected memories are rendered as typed XML-like elements with escaped content, which keeps
@@ -15,7 +15,7 @@ module Engram
15
15
  def add(record)
16
16
  validate_scope!(record.scope)
17
17
 
18
- record.id ||= (@sequence += 1)
18
+ record.id = (@sequence += 1)
19
19
  @records[record.id] = record
20
20
  record
21
21
  end
@@ -44,6 +44,10 @@ module Engram
44
44
  records
45
45
  end
46
46
 
47
+ def existing_ids(scope:, ids:)
48
+ ids.uniq.select { |id| @records[id]&.scope == scope }
49
+ end
50
+
47
51
  def update(scope:, id:, record:)
48
52
  existing = @records[id]
49
53
  raise Engram::Error, "no memory with id #{id.inspect} in scope #{scope.inspect}" unless existing&.scope == scope
@@ -49,6 +49,21 @@ module Engram
49
49
  query.map { |row| to_record(row) }
50
50
  end
51
51
 
52
+ def existing_ids(scope:, ids:)
53
+ persisted_ids = model.where(scope: scope, id: ids).pluck(:id)
54
+ id_type = model.type_for_attribute("id")
55
+ persisted_lookup = persisted_ids.each_with_object({}) { |id, lookup| lookup[id] = true }
56
+ matched_ids = {}
57
+
58
+ ids.select do |id|
59
+ cast_id = id_type.cast(id)
60
+ next false unless persisted_lookup[cast_id]
61
+ next false if matched_ids[cast_id]
62
+
63
+ matched_ids[cast_id] = true
64
+ end
65
+ end
66
+
52
67
  def update(scope:, id:, record:)
53
68
  raise Engram::Error, "cannot move memory across scopes" unless record.scope == scope
54
69
 
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Engram
4
+ # An immutable extractor result that carries a candidate Record and its source provenance.
5
+ # Converting it creates a new record and leaves the caller-owned record and metadata untouched.
6
+ class Extraction
7
+ attr_reader :record, :provenance
8
+
9
+ def initialize(record:, provenance:)
10
+ is_record = Object.instance_method(:is_a?).bind_call(record, Engram::Record)
11
+ raise ArgumentError, "record must be an Engram::Record" unless is_record
12
+
13
+ is_provenance = Object.instance_method(:is_a?).bind_call(provenance, Engram::Provenance)
14
+ raise ArgumentError, "provenance must be an Engram::Provenance" unless is_provenance
15
+
16
+ @record = record
17
+ @provenance = provenance
18
+ freeze
19
+ end
20
+
21
+ def to_record
22
+ source = Engram::Internal::CandidateIntegrity.new.detach(record)
23
+ attributes = Engram::Record::STATE_READERS.to_h do |attribute|
24
+ [attribute, Engram::Record.instance_method(attribute).bind_call(source)]
25
+ end
26
+ attributes[:metadata] = Engram::Provenance.attach(attributes.fetch(:metadata), provenance)
27
+ Engram::Record.new(**attributes)
28
+ end
29
+ end
30
+ end