engram 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +113 -0
  3. data/README.md +217 -11
  4. data/lib/engram/adapters/in_memory_processed_turns.rb +40 -8
  5. data/lib/engram/adapters/in_memory_store.rb +30 -11
  6. data/lib/engram/adapters/null_embedder.rb +9 -0
  7. data/lib/engram/adapters/pgvector_store.rb +50 -17
  8. data/lib/engram/adapters/ruby_llm_embedder.rb +45 -4
  9. data/lib/engram/consolidators/heuristic_consolidator.rb +7 -1
  10. data/lib/engram/consolidators/llm_consolidator.rb +37 -10
  11. data/lib/engram/embedding_metadata.rb +135 -0
  12. data/lib/engram/extraction.rb +30 -0
  13. data/lib/engram/extractors/llm_extractor.rb +4 -3
  14. data/lib/engram/internal/candidate_integrity.rb +510 -0
  15. data/lib/engram/internal/core_hash.rb +36 -0
  16. data/lib/engram/internal/scope.rb +31 -0
  17. data/lib/engram/memory.rb +28 -1
  18. data/lib/engram/persistence.rb +83 -8
  19. data/lib/engram/persistence_policy.rb +11 -1
  20. data/lib/engram/ports/consolidator.rb +7 -2
  21. data/lib/engram/ports/extractor.rb +1 -1
  22. data/lib/engram/ports/memory_store.rb +25 -7
  23. data/lib/engram/ports/processed_turns.rb +16 -8
  24. data/lib/engram/provenance.rb +588 -0
  25. data/lib/engram/rails/cache_processed_turns.rb +51 -10
  26. data/lib/engram/rails/observe_job.rb +5 -0
  27. data/lib/engram/rails/tasks.rake +26 -0
  28. data/lib/engram/railtie.rb +4 -0
  29. data/lib/engram/record.rb +12 -5
  30. data/lib/engram/reserved_metadata.rb +52 -0
  31. data/lib/engram/use_cases/forget.rb +6 -2
  32. data/lib/engram/use_cases/grounding_report.rb +44 -0
  33. data/lib/engram/use_cases/observe.rb +300 -26
  34. data/lib/engram/use_cases/rebuild_embeddings.rb +189 -0
  35. data/lib/engram/use_cases/recall.rb +12 -4
  36. data/lib/engram/use_cases/source_impact.rb +42 -0
  37. data/lib/engram/version.rb +1 -1
  38. data/lib/engram.rb +13 -0
  39. metadata +14 -3
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 412e5e9bcb45b4889a24f5b6739a476b84057c3a62aca2bd767856cd4f725e3e
4
- data.tar.gz: 26b9be259f6e937ba91a432bfa12f566c65a721ea49fefb7fa0a057c9fa435af
3
+ metadata.gz: c623f8198a9f71905e6e0c904b6cf60d5d6a0734b1e8fdaf877611a815f2a919
4
+ data.tar.gz: aca21e3da50239a5d02822306524adc6647883ef607af57747b05ce7ecb78827
5
5
  SHA512:
6
- metadata.gz: fcd14fc54223897ed9d342ee574e279af725507b7653ff7545955f9b15e2815962edb40fdbf1c018fb99813fab84229ad061249fc666f9ff398f7826dede4da0
7
- data.tar.gz: d27dfc3039f3c2e4dcd466be5c1481036dc823e6e1760e966eb8bd76dca4ee22ed2e71eae503ab2355a9696a05b000bf014efc6798e7b344ae4f975db48854f3
6
+ metadata.gz: 55f5e8335a86ec28cd28b17af162883fcc7c58dc56b903fc18b924c4278b593d9c8945cbf19cb28649a4c66de57ff38e6af46f3c50b15a6a96a8d729b90fc6ba
7
+ data.tar.gz: 519081acdc6f1b284df51bc664d274ca31befbc2d7a002ef9d62fee2f31c165f64c2896ab5f2dddca771b02c2052dcb630ef785e440776931d53d7f994de6e47
data/CHANGELOG.md CHANGED
@@ -5,6 +5,119 @@ 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
+
64
+ ## [0.5.0] - 2026-07-17
65
+
66
+ ### Added
67
+ - Embedding provenance metadata is stored with new memories so applications can detect model
68
+ and dimension drift during store search result validation.
69
+ - `Memory#rebuild_embeddings` and `Engram::UseCases::RebuildEmbeddings` for scoped,
70
+ deterministic rebuilding of stale vectors plus provenance metadata.
71
+ - Added a focused rake task `engram:rebuild_embeddings` with batch control and optional
72
+ forced full-scope rebuild mode for recovery after provider/model changes. The task is
73
+ packaged with the gem and loaded into host Rails apps by the Railtie, where it depends on
74
+ `:environment` so app initializers run first. `STALE_ONLY` accepts `false`, `0`, or `no`.
75
+
76
+ ### Changed
77
+ - `MemoryStore` mutations now require an explicit `scope:` and enforce the `(scope, id)` boundary
78
+ for update, delete, and touch operations. Custom stores must adopt the new scoped signatures;
79
+ `update` returns the updated record or raises `Engram::Error`, while delete and touch return an
80
+ affected-row count.
81
+ - **Breaking (pre-1.0):** the `ProcessedTurns` port migrated from the previous check/mark API
82
+ to `claim`, `complete`, `release`, and `completed?`; custom adapters must implement the new
83
+ claim lifecycle. A successful claim returns a truthy opaque token.
84
+ - Observation idempotency now uses atomic, scope-aware claims with an in-progress lease and
85
+ completed state. `InMemoryProcessedTurns` is thread-safe and releases failures immediately.
86
+ Generic cache release is a no-op until lease expiry because ActiveSupport cache has no atomic
87
+ compare-and-delete; completion never deletes a possibly newer claim. Completed markers
88
+ suppress later calls only for their configured `ttl`. A failed completion write leaves the
89
+ lease to suppress work until expiry, after which already-applied work may replay. `lease_ttl`
90
+ should cover the longest observation while remaining much shorter than `ttl`, and
91
+ `Rails::CacheProcessedTurns` uses atomic `unless_exist` writes with separate claim and
92
+ completion keys. This coordinates concurrent work but does not make multi-decision memory
93
+ persistence and claim completion a crash-proof transaction. Lease expiry may permit overlap,
94
+ so claims do not guarantee ownership, fencing, or exactly-once execution.
95
+ - Added API stability and migration posture documentation for pre-1.0 freeze planning, including public surface boundaries and legacy compatibility points.
96
+ - Store search result validation now raises a clear `Engram::Error` when stored embedding
97
+ metadata or vector dimensions conflict with the active embedder, while legacy records without
98
+ metadata remain searchable when their vector dimensions match the active embedder.
99
+ - Caller metadata keys named `_engram` are now reserved for Engram-owned embedding provenance;
100
+ rename any application metadata stored under that key before adding new memories.
101
+ - `RubyLLMEmbedder` now requests explicitly configured `dimensions:` from the provider (on
102
+ RubyLLM versions that support the option) and validates every returned vector against the
103
+ configured dimensions, raising a clear `Engram::Error` on mismatch. Previously the option
104
+ was recorded in metadata but never sent or checked, so a mismatch surfaced later as opaque
105
+ pgvector insert failures. If you use a model whose native vector size differs from 1536,
106
+ set `dimensions:` to that model's actual output size.
107
+
108
+ ### Fixed
109
+ - `Observe` now raises `Engram::ObservationInProgressError` when a turn is claimed but not
110
+ completed, rather than reporting a successful no-op. `ObserveJob` retries this error with
111
+ backoff that outlasts the default claim lease; direct callers should handle it as retryable.
112
+ - Stale detection in `rebuild_embeddings` now compares against the embedder's declared
113
+ dimensions instead of the stored record's vector length, so a dimensions-only embedder
114
+ change marks existing rows stale. Previously the default stale-only rebuild silently
115
+ skipped every row after such a change. Embedders that do not declare dimensions keep the
116
+ previous record-length comparison.
117
+ - `LLMConsolidator` now ignores malformed decisions and rejects `update`/`forget` targets
118
+ that were not shown to the model, preventing invalid model output from partially applying
119
+ a turn. Cross-scope updates from custom consolidators still raise in `Observe`.
120
+
8
121
  ## [0.4.0] - 2026-06-06
9
122
 
10
123
  ### 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.
@@ -61,6 +61,41 @@ chat.ask("Why am I being rate limited?")
61
61
  - Persistence policy that rejects obvious secrets and transient task-progress updates before storage.
62
62
  - Idempotent observation, recency/importance-aware ranking, recall touching, and stale-memory pruning.
63
63
 
64
+
65
+ ## API stability and migration posture
66
+
67
+ Engram is intentionally marked pre-1.0; default behavior is to prioritize safety and compatibility, while still allowing occasional focused API additions.
68
+
69
+ ### Current public surface
70
+
71
+ The following are part of the documented public surface and should remain stable except where
72
+ explicitly version-gated:
73
+
74
+ - Core types and facade: `Engram::Memory`, `Engram::Record`, `Engram::Decision`, `Engram::PersistencePolicy`, and `Engram.with_memory`.
75
+ - Store and adapter ports: `Engram::Ports::MemoryStore`, `Engram::Ports::Embedder`, `Engram::Ports::Completion`.
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`, `rebuild_embeddings`, and `memories_from_source`.
78
+ - RubyLLM adapter contract points and evaluator entrypoints (`rake eval`, `rake eval:real`).
79
+
80
+ ### Backward-compatibility commitments (pre-1.0)
81
+
82
+ - Compatibility adjustments should be additive where possible.
83
+ - Behavioral changes that could break callers should be documented under `CHANGELOG.md` and tested with migration scenarios.
84
+ - Legacy compatibility notes already in effect:
85
+ - `kind: "semantic"` is normalized to `:fact` for read paths.
86
+ - Existing rows created without embedding provenance are still readable when dimensions match the active embedder.
87
+
88
+ ### 1.0 pre-freeze checklist
89
+
90
+ Before the 1.0 release marker, freeze the public API by:
91
+
92
+ 1. Finalizing configuration and initializer keys in docs and examples.
93
+ 2. Writing upgrade notes for any remaining behavior-shifting defaults.
94
+ 3. Verifying migrations/rebuild flows for legacy rows remain observable and recoverable.
95
+ 4. Keeping security and persistence-policy boundaries explicit in host-app guidance.
96
+
97
+ Use `CHANGELOG.md` as the authoritative source for breaking/compatibility changes while still in pre-1.0.
98
+
64
99
  ## Installation
65
100
 
66
101
  ```ruby
@@ -68,7 +103,7 @@ chat.ask("Why am I being rate limited?")
68
103
  gem "engram"
69
104
  ```
70
105
 
71
- 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:
72
107
 
73
108
  - `Engram::Adapters::PgvectorStore` → ActiveRecord + `neighbor` + Postgres/pgvector
74
109
  - `Engram::Adapters::RubyLLMEmbedder` and `Engram::Adapters::RubyLLMCompletion` → `ruby_llm`
@@ -112,15 +147,23 @@ current_user.memory.observe_later([
112
147
  ```
113
148
 
114
149
  `observe_later` uses ActiveJob, so configure the queue adapter you already use in
115
- production (Sidekiq, Solid Queue, GoodJob, etc.). For idempotency across retries and
116
- processes, use the Rails cache-backed processed-turn store:
150
+ production (Sidekiq, Solid Queue, GoodJob, etc.). To coordinate observation claims across
151
+ retries and processes, use the Rails cache-backed processed-turn store with a shared cache
152
+ whose `write(..., unless_exist: true)` operation is atomic (for example Redis or Solid Cache):
117
153
 
118
154
  ```ruby
119
155
  Engram.configure do |config|
120
- config.processed_turns = Engram::Rails::CacheProcessedTurns.new
156
+ config.processed_turns = Engram::Rails::CacheProcessedTurns.new(lease_ttl: 5.minutes)
121
157
  end
122
158
  ```
123
159
 
160
+ The adapter rejects backends that do not return a boolean result for `unless_exist`, but it
161
+ cannot detect a backend that accepts the option without implementing it atomically. Rails'
162
+ memory cache coordinates threads in one process only, and `NullStore` is not suitable.
163
+ Completed suppression is bounded by `ttl` (24 hours by default). Set `lease_ttl` longer than
164
+ the longest expected observation, but normally much shorter than `ttl`, so crashed work can
165
+ be retried promptly without weakening the completed-turn suppression window.
166
+
124
167
  ## Postgres + pgvector setup
125
168
 
126
169
  The Rails generator creates an `engram_memories` table with a `vector` extension and a
@@ -153,7 +196,9 @@ If you change embedding models, keep the database column dimension in sync with
153
196
  embedding vector length. A model that returns 768-dimensional vectors needs a 768-dimensional
154
197
  `vector` column; a 1536-dimensional migration will not be compatible with it. The install
155
198
  generator rejects non-positive or non-integer `--dimensions` values so an invalid vector
156
- size does not land in a migration.
199
+ size does not land in a migration. `RubyLLMEmbedder` requests explicitly configured
200
+ `dimensions:` from the provider (models like `text-embedding-3-*` support shortening) and
201
+ raises a clear error when the model's output does not match the configured dimensions.
157
202
 
158
203
  For production recall performance, add one approximate vector index after the table has
159
204
  representative data. HNSW is the recommended default for read-heavy applications because it
@@ -246,6 +291,25 @@ memory.observe([
246
291
  # extracts "User is on the Pro plan", and if a "Free plan" memory exists, updates it
247
292
  ```
248
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
+
249
313
  ## Memory kinds and persistence policy
250
314
 
251
315
  Every memory has a normalized `kind`:
@@ -284,6 +348,107 @@ Engram.configure do |config|
284
348
  end
285
349
  ```
286
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
+
287
452
  ## Prompt-injection and memory-injection safety
288
453
 
289
454
  Injected memories are rendered as typed XML-like elements with escaped content, which keeps
@@ -317,9 +482,28 @@ with the old `semantic` kind value.
317
482
 
318
483
  ## Tuning and maintenance
319
484
 
320
- Observation is idempotent per turn: observing the same messages twice does nothing the
321
- second time, so retries do not create duplicate memories or repeat LLM calls. In Rails,
322
- use a persistent processed-turn store so this also holds across job retries and processes.
485
+ Observation uses a scope-and-turn claim before extraction. While a claim lease is live and
486
+ the turn has not completed, calls for the same scope and turn raise
487
+ `Engram::ObservationInProgressError` instead of reporting success without doing the work; a
488
+ completed marker suppresses later calls (returning no decisions) until its configured `ttl`
489
+ expires. `ObserveJob` retries `ObservationInProgressError` with polynomial backoff that
490
+ outlasts the default lease. Direct `observe` callers should also treat the error as retryable.
491
+ The in-memory adapter releases failures immediately. Generic Rails cache release
492
+ is deliberately a no-op until lease expiry because ActiveSupport cache has no atomic
493
+ compare-and-delete. In Rails, use a shared cache with atomic `unless_exist` writes for
494
+ cross-process coordination. Set `lease_ttl` longer than the longest expected observation but
495
+ much shorter than the completed-marker `ttl`; after a worker crash (or an overlong
496
+ observation), the lease expires so a retry can proceed.
497
+
498
+ Lease expiry can permit old and new workers to overlap; claims are not fencing tokens or an
499
+ ownership guarantee. Successful work records completion but cannot safely delete a possibly
500
+ newer cache claim. This is idempotency coordination, not crash-proof exactly-once persistence. An observation
501
+ can apply multiple decisions, and the memory writes plus completed marker are not one
502
+ transaction. A crash, cache outage, or lease expiry between those operations can permit a
503
+ retry after some decisions were already written. Applications needing stronger guarantees
504
+ must supply transactional persistence/outbox coordination appropriate to their store.
505
+ If writing the completed marker fails after memory writes succeed, the current lease still
506
+ suppresses retries until it expires; after expiry the turn may replay and repeat those writes.
323
507
 
324
508
  Recall is plain similarity search by default. You can blend in importance and recency:
325
509
 
@@ -338,6 +522,27 @@ Prune memories you no longer need:
338
522
  current_user.memory.forget_stale(older_than: 90 * 24 * 60 * 60, min_importance: 0.7)
339
523
  ```
340
524
 
525
+ If you change embedder configuration (model/provider/dimensions), or if legacy memories were
526
+ written without embedding provenance, run a scoped rebuild to refresh vectors and metadata:
527
+
528
+ ```bash
529
+ bundle exec rake "engram:rebuild_embeddings[user:42]"
530
+ ```
531
+
532
+ The task ships with the gem and is loaded by the Railtie, so it is available inside Rails
533
+ applications and boots the app environment (initializers included) before running. Outside
534
+ Rails, call `memory.rebuild_embeddings` directly.
535
+
536
+ By default, only stale rows are rewritten. Set `STALE_ONLY=false` to rebuild all rows in
537
+ the scope, and `BATCH_SIZE=<n>` to tune write size.
538
+
539
+ The rebuild has no dry-run mode and updates each row as it goes. It assumes the store exposes
540
+ a stable, finite, scope-isolated traversal; malformed adapters that repeat forever cannot be
541
+ made deterministic by the rebuild. Rows without IDs are counted and skipped. Choose a batch
542
+ size that fits provider and database limits, take a backup first, and monitor provider usage,
543
+ errors, and database load. The final summary reports processed, updated, skipped, and failed
544
+ counts; failures also list record IDs and cause the rake task to exit unsuccessfully.
545
+
341
546
  ## Observability
342
547
 
343
548
  When ActiveSupport is loaded, Engram emits `ActiveSupport::Notifications` events for the
@@ -478,8 +683,9 @@ gem unpack engram-*.gem --target /tmp/engram-package-check
478
683
  - v0.1 (done): recall + inject foundation, adapters, Rails + RubyLLM integration.
479
684
  - v0.2 (done): extract and consolidate (ADD / UPDATE / FORGET), background jobs.
480
685
  - v0.3 (done): idempotent observation, importance/recency recall, forgetting and decay.
481
- - v0.4 (in progress): memory kinds, persistence policy, typed recall filters, safer injection, and release-readiness docs.
482
- - later: real-provider eval ergonomics, additional storage backends, observability hooks, and larger eval benchmarks.
686
+ - v0.4 (done): memory kinds, persistence policy, typed recall filters, safer injection, and observability hooks.
687
+ - v0.5 (in progress): embedding provenance and scoped embedding rebuild operations.
688
+ - later: additional storage backends and larger real-provider eval benchmarks.
483
689
 
484
690
  ## License
485
691
 
@@ -8,21 +8,53 @@ module Engram
8
8
  class InMemoryProcessedTurns
9
9
  include Ports::ProcessedTurns
10
10
 
11
- def initialize
12
- @keys = Set.new
11
+ def initialize(lease_ttl: 300, clock: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
12
+ @lease_ttl = lease_ttl
13
+ @clock = clock
14
+ @claims = {}
15
+ @completed = Set.new
16
+ @mutex = Mutex.new
13
17
  end
14
18
 
15
- def seen?(key)
16
- @keys.include?(key)
19
+ def claim(scope:, key:)
20
+ scoped_key = [scope, key]
21
+ @mutex.synchronize do
22
+ return if @completed.include?(scoped_key)
23
+ live = @claims[scoped_key]
24
+ return if live && live.last > @clock.call
25
+
26
+ token = Object.new
27
+ @claims[scoped_key] = [token, @clock.call + @lease_ttl]
28
+ token
29
+ end
30
+ end
31
+
32
+ def complete(scope:, key:, claim:)
33
+ @mutex.synchronize do
34
+ scoped_key = [scope, key]
35
+ return false unless @claims.dig(scoped_key, 0).equal?(claim)
36
+ @completed << scoped_key
37
+ @claims.delete(scoped_key)
38
+ true
39
+ end
40
+ end
41
+
42
+ def release(scope:, key:, claim:)
43
+ @mutex.synchronize do
44
+ scoped_key = [scope, key]
45
+ @claims.delete(scoped_key) if @claims.dig(scoped_key, 0).equal?(claim)
46
+ end
17
47
  end
18
48
 
19
- def record(key)
20
- @keys << key
21
- key
49
+ def completed?(scope:, key:)
50
+ @mutex.synchronize { @completed.include?([scope, key]) }
22
51
  end
23
52
 
24
53
  def clear
25
- @keys.clear
54
+ @mutex.synchronize do
55
+ @claims.clear
56
+ @completed.clear
57
+ end
26
58
  end
27
59
  end
28
60
  end
@@ -15,42 +15,61 @@ 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
22
22
 
23
- def search(embedding:, scope:, limit:, kinds: nil)
23
+ def search(embedding:, scope:, limit:, kinds: nil, embedding_metadata: nil)
24
+ Engram::EmbeddingMetadata.validate_query!(embedding, embedding_metadata)
24
25
  allowed_kinds = normalize_kinds(kinds)
25
26
 
26
- @records
27
+ results = @records
27
28
  .values
28
29
  .select { |r| searchable?(r, scope, allowed_kinds) }
29
30
  .map { |r| [r, Engram::Math.cosine_similarity(embedding, r.embedding)] }
30
31
  .sort_by { |(_, score)| -score }
31
32
  .first(limit)
32
33
  .map { |(record, _)| record }
34
+
35
+ results.each { |record| Engram::EmbeddingMetadata.validate_record!(record, embedding, embedding_metadata) }
36
+ results
37
+ end
38
+
39
+ def all(scope:, limit: nil, offset: 0, after_id: nil)
40
+ records = @records.values.select { |r| r.scope == scope }.sort_by { |record| record.id }
41
+ records = records.drop_while { |record| !after_id.nil? && record.id && record.id <= after_id }
42
+ records = records.drop(offset) if offset > 0
43
+ records = records.take(limit) if limit
44
+ records
33
45
  end
34
46
 
35
- def all(scope:)
36
- @records.values.select { |r| r.scope == scope }
47
+ def existing_ids(scope:, ids:)
48
+ ids.uniq.select { |id| @records[id]&.scope == scope }
37
49
  end
38
50
 
39
- def update(id:, record:)
40
- raise Engram::Error, "no memory with id #{id.inspect}" unless @records.key?(id)
51
+ def update(scope:, id:, record:)
52
+ existing = @records[id]
53
+ raise Engram::Error, "no memory with id #{id.inspect} in scope #{scope.inspect}" unless existing&.scope == scope
54
+ raise Engram::Error, "cannot move memory across scopes" unless record.scope == scope
41
55
 
42
56
  record.id = id
43
57
  @records[id] = record
44
58
  end
45
59
 
46
- def delete(id:)
60
+ def delete(scope:, id:)
61
+ return 0 unless @records[id]&.scope == scope
62
+
47
63
  @records.delete(id)
64
+ 1
48
65
  end
49
66
 
50
- def touch(id:, at: Time.now)
67
+ def touch(scope:, id:, at: Time.now)
51
68
  record = @records[id]
52
- record.last_accessed_at = at if record
53
- record
69
+ return 0 unless record&.scope == scope
70
+
71
+ record.last_accessed_at = at
72
+ 1
54
73
  end
55
74
 
56
75
  def clear
@@ -16,6 +16,15 @@ module Engram
16
16
 
17
17
  attr_reader :dimensions
18
18
 
19
+ def embedding_metadata
20
+ Engram::EmbeddingMetadata.build(
21
+ adapter: self.class.name,
22
+ provider: "null",
23
+ model: "null-embedder-v1",
24
+ dimensions: dimensions
25
+ )
26
+ end
27
+
19
28
  def embed(text)
20
29
  seed = Digest::SHA256.hexdigest(text.to_s)
21
30
  Array.new(@dimensions) do |i|