engram 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +56 -0
- data/README.md +123 -3
- data/lib/engram/adapters/in_memory_store.rb +5 -1
- data/lib/engram/adapters/pgvector_store.rb +15 -0
- data/lib/engram/extraction.rb +30 -0
- data/lib/engram/internal/candidate_integrity.rb +510 -0
- data/lib/engram/internal/core_hash.rb +36 -0
- data/lib/engram/internal/scope.rb +31 -0
- data/lib/engram/memory.rb +15 -0
- data/lib/engram/persistence.rb +79 -9
- data/lib/engram/persistence_policy.rb +11 -1
- data/lib/engram/ports/consolidator.rb +7 -2
- data/lib/engram/ports/extractor.rb +1 -1
- data/lib/engram/ports/memory_store.rb +11 -0
- data/lib/engram/provenance.rb +349 -18
- data/lib/engram/record.rb +12 -5
- data/lib/engram/use_cases/grounding_report.rb +44 -0
- data/lib/engram/use_cases/observe.rb +266 -13
- data/lib/engram/use_cases/source_impact.rb +42 -0
- data/lib/engram/version.rb +1 -1
- data/lib/engram.rb +6 -0
- metadata +8 -2
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,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
|
-
-
|
|
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 `
|
|
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
|
|
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
|
|
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
|