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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +113 -0
- data/README.md +217 -11
- data/lib/engram/adapters/in_memory_processed_turns.rb +40 -8
- data/lib/engram/adapters/in_memory_store.rb +30 -11
- data/lib/engram/adapters/null_embedder.rb +9 -0
- data/lib/engram/adapters/pgvector_store.rb +50 -17
- data/lib/engram/adapters/ruby_llm_embedder.rb +45 -4
- data/lib/engram/consolidators/heuristic_consolidator.rb +7 -1
- data/lib/engram/consolidators/llm_consolidator.rb +37 -10
- data/lib/engram/embedding_metadata.rb +135 -0
- data/lib/engram/extraction.rb +30 -0
- data/lib/engram/extractors/llm_extractor.rb +4 -3
- 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 +28 -1
- data/lib/engram/persistence.rb +83 -8
- 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 +25 -7
- data/lib/engram/ports/processed_turns.rb +16 -8
- data/lib/engram/provenance.rb +588 -0
- data/lib/engram/rails/cache_processed_turns.rb +51 -10
- data/lib/engram/rails/observe_job.rb +5 -0
- data/lib/engram/rails/tasks.rake +26 -0
- data/lib/engram/railtie.rb +4 -0
- data/lib/engram/record.rb +12 -5
- data/lib/engram/reserved_metadata.rb +52 -0
- data/lib/engram/use_cases/forget.rb +6 -2
- data/lib/engram/use_cases/grounding_report.rb +44 -0
- data/lib/engram/use_cases/observe.rb +300 -26
- data/lib/engram/use_cases/rebuild_embeddings.rb +189 -0
- data/lib/engram/use_cases/recall.rb +12 -4
- data/lib/engram/use_cases/source_impact.rb +42 -0
- data/lib/engram/version.rb +1 -1
- data/lib/engram.rb +13 -0
- metadata +14 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c623f8198a9f71905e6e0c904b6cf60d5d6a0734b1e8fdaf877611a815f2a919
|
|
4
|
+
data.tar.gz: aca21e3da50239a5d02822306524adc6647883ef607af57747b05ce7ecb78827
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
-
|
|
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
|
|
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.).
|
|
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
|
|
321
|
-
|
|
322
|
-
|
|
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 (
|
|
482
|
-
-
|
|
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
|
-
@
|
|
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
|
|
16
|
-
|
|
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
|
|
20
|
-
@
|
|
21
|
-
key
|
|
49
|
+
def completed?(scope:, key:)
|
|
50
|
+
@mutex.synchronize { @completed.include?([scope, key]) }
|
|
22
51
|
end
|
|
23
52
|
|
|
24
53
|
def clear
|
|
25
|
-
@
|
|
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
|
|
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
|
|
36
|
-
|
|
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
|
-
|
|
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
|
-
|
|
53
|
-
|
|
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|
|