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
data/lib/engram/persistence.rb
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
module Engram
|
|
4
4
|
# Applies persistence hooks and policy consistently before writing records.
|
|
5
5
|
class Persistence
|
|
6
|
+
RECORD_METADATA_READER = Engram::Record.instance_method(:metadata)
|
|
7
|
+
|
|
6
8
|
def initialize(store:, embedder:, before_persist: Engram.config.before_persist,
|
|
7
9
|
persistence_policy: Engram.config.persistence_policy)
|
|
8
10
|
@store = store
|
|
@@ -11,24 +13,97 @@ module Engram
|
|
|
11
13
|
@persistence_policy = persistence_policy
|
|
12
14
|
end
|
|
13
15
|
|
|
14
|
-
def add(record)
|
|
15
|
-
|
|
16
|
-
@store.add(record) if record
|
|
16
|
+
def add(record, scope: record.scope)
|
|
17
|
+
add_prepared(prepare(record), scope: scope)
|
|
17
18
|
end
|
|
18
19
|
|
|
19
|
-
def update(id:, record:)
|
|
20
|
-
record
|
|
21
|
-
@store.update(id: id, record: record) if record
|
|
20
|
+
def update(scope:, id:, record:)
|
|
21
|
+
update_prepared(scope: scope, id: id, record: prepare(record))
|
|
22
22
|
end
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
# Persists a record already returned by #prepare. These split-phase methods let
|
|
25
|
+
# orchestrators prepare a complete batch before beginning store mutations.
|
|
26
|
+
def add_prepared(record, scope: record&.scope)
|
|
27
|
+
return unless record
|
|
28
|
+
|
|
29
|
+
record = canonicalize_provenance!(record)
|
|
30
|
+
unless Engram::Internal::Scope.record_matches?(record, scope)
|
|
31
|
+
raise Engram::Error, "cannot move memory across scopes"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
@store.add(record)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def update_prepared(scope:, id:, record:)
|
|
38
|
+
return unless record
|
|
25
39
|
|
|
40
|
+
record = canonicalize_provenance!(record)
|
|
41
|
+
unless Engram::Internal::Scope.record_matches?(record, scope)
|
|
42
|
+
raise Engram::Error, "cannot move memory across scopes"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
@store.update(scope: scope, id: id, record: record)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Applies all write transformations without mutating the store. Input is validated
|
|
49
|
+
# before callbacks can remove or replace provenance, and final output is validated too.
|
|
26
50
|
def prepare(record)
|
|
51
|
+
original_provenance = validate_provenance!(record)
|
|
27
52
|
original_content = record.content
|
|
28
53
|
record = @before_persist.call(record) if @before_persist
|
|
54
|
+
if record
|
|
55
|
+
transformed_provenance = validate_provenance!(record)
|
|
56
|
+
validate_provenance_trust!(original_provenance, transformed_provenance) if @before_persist
|
|
57
|
+
end
|
|
29
58
|
record = @persistence_policy.call(record) if record && @persistence_policy
|
|
30
|
-
|
|
59
|
+
validate_provenance!(record) if record
|
|
60
|
+
if record && record.content != original_content
|
|
61
|
+
record = record.with(embedding: @embedder.embed(record.content))
|
|
62
|
+
end
|
|
63
|
+
record = Engram::EmbeddingMetadata.attach(record, embedder: @embedder) if record
|
|
64
|
+
validate_provenance!(record) if record
|
|
31
65
|
record
|
|
32
66
|
end
|
|
67
|
+
|
|
68
|
+
# Authorizes a destructive decision without applying write transformations or content
|
|
69
|
+
# filtering. Policies may opt into this separate contract with #allow_destructive?.
|
|
70
|
+
# Malformed provenance always fails closed.
|
|
71
|
+
def allowed?(record)
|
|
72
|
+
validate_provenance!(record)
|
|
73
|
+
return true unless @persistence_policy&.respond_to?(:allow_destructive?)
|
|
74
|
+
|
|
75
|
+
authorization = @persistence_policy.allow_destructive?(record)
|
|
76
|
+
unless authorization.equal?(true) || authorization.equal?(false)
|
|
77
|
+
raise Engram::Error, "persistence policy allow_destructive? must return true or false"
|
|
78
|
+
end
|
|
79
|
+
validate_provenance!(record)
|
|
80
|
+
|
|
81
|
+
authorization
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def canonicalize_provenance!(record)
|
|
87
|
+
metadata = RECORD_METADATA_READER.bind_call(record)
|
|
88
|
+
canonical_metadata = Engram::Provenance.canonical_metadata_for_persistence(metadata)
|
|
89
|
+
return record if canonical_metadata.equal?(metadata)
|
|
90
|
+
|
|
91
|
+
record.with(metadata: canonical_metadata)
|
|
92
|
+
rescue TypeError
|
|
93
|
+
raise Engram::Error, "persistence requires an Engram::Record"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def validate_provenance!(record)
|
|
97
|
+
metadata = RECORD_METADATA_READER.bind_call(record)
|
|
98
|
+
Engram::Provenance.canonical_integrity_representation_for_persistence(metadata)
|
|
99
|
+
rescue TypeError
|
|
100
|
+
raise Engram::Error, "persistence requires an Engram::Record"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def validate_provenance_trust!(original, transformed)
|
|
104
|
+
return if original == transformed
|
|
105
|
+
|
|
106
|
+
raise Engram::Error, "before_persist cannot change provenance trust"
|
|
107
|
+
end
|
|
33
108
|
end
|
|
34
109
|
end
|
|
@@ -16,16 +16,26 @@ module Engram
|
|
|
16
16
|
/\b(?:today|now|this session)\b.*\b(?:fixed|resolved|done|completed|finished)\b/i
|
|
17
17
|
].freeze
|
|
18
18
|
|
|
19
|
-
def initialize(denylist_patterns: [])
|
|
19
|
+
def initialize(denylist_patterns: [], allow_ungrounded: false)
|
|
20
20
|
@denylist_patterns = denylist_patterns
|
|
21
|
+
@allow_ungrounded = BasicObject.instance_method(:equal?).bind_call(allow_ungrounded, true)
|
|
21
22
|
end
|
|
22
23
|
|
|
23
24
|
def call(record)
|
|
25
|
+
provenance = Engram::Provenance.extract_for_persistence(record.metadata)
|
|
26
|
+
return nil if provenance&.ungrounded? && !@allow_ungrounded
|
|
24
27
|
return nil if reject?(record.content)
|
|
25
28
|
|
|
26
29
|
redact(record)
|
|
27
30
|
end
|
|
28
31
|
|
|
32
|
+
# Destructive authorization is deliberately independent from write-content filtering and
|
|
33
|
+
# redaction. Provenance trust still applies, but secret/transient content can always be removed.
|
|
34
|
+
def allow_destructive?(record)
|
|
35
|
+
provenance = Engram::Provenance.extract_for_persistence(record.metadata)
|
|
36
|
+
!provenance&.ungrounded? || @allow_ungrounded
|
|
37
|
+
end
|
|
38
|
+
|
|
29
39
|
private
|
|
30
40
|
|
|
31
41
|
def reject?(content)
|
|
@@ -7,8 +7,13 @@ module Engram
|
|
|
7
7
|
# dumb pile of embeddings.
|
|
8
8
|
# Implementations: Consolidators::HeuristicConsolidator, Consolidators::LLMConsolidator.
|
|
9
9
|
module Consolidator
|
|
10
|
-
# Given Array<Record> candidates and a scope, return Array<Decision> (one
|
|
11
|
-
# candidate
|
|
10
|
+
# Given Array<Record> candidates and a scope, return Array<Decision> (at most one
|
|
11
|
+
# per candidate occurrence, including NOOP decisions). Each decision must reference
|
|
12
|
+
# the actual candidate instance from this array, not a copy or replacement. When the
|
|
13
|
+
# same instance occurs multiple times, it may have no more decisions than occurrences.
|
|
14
|
+
# Candidates are read-only reconciliation inputs: implementations must not mutate
|
|
15
|
+
# their state, including nested metadata or embedding values. Decision target IDs
|
|
16
|
+
# must be plain String or Integer values without singleton behavior or custom state.
|
|
12
17
|
def reconcile_all(candidates:, scope:)
|
|
13
18
|
raise NotImplementedError, "#{self.class} must implement #reconcile_all"
|
|
14
19
|
end
|
|
@@ -6,7 +6,7 @@ module Engram
|
|
|
6
6
|
# Declared now so the differentiator (extract -> consolidate) slots in without
|
|
7
7
|
# reworking the core. Not implemented in v0.1.
|
|
8
8
|
module Extractor
|
|
9
|
-
# Given conversation messages, return Array
|
|
9
|
+
# Given conversation messages, return an Array whose members are Record or Extraction values.
|
|
10
10
|
def extract(messages:, scope:)
|
|
11
11
|
raise NotImplementedError, "Extractor arrives in v0.2"
|
|
12
12
|
end
|
|
@@ -13,28 +13,46 @@ module Engram
|
|
|
13
13
|
# Return up to `limit` Records in `scope` nearest to `embedding`,
|
|
14
14
|
# ordered most-relevant first. When `kinds` is provided, only records with
|
|
15
15
|
# those canonical memory kinds are eligible.
|
|
16
|
-
def search(embedding:, scope:, limit:, kinds: nil)
|
|
16
|
+
def search(embedding:, scope:, limit:, kinds: nil, embedding_metadata: nil)
|
|
17
17
|
raise NotImplementedError, "#{self.class} must implement #search"
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
# All Records for a scope (mostly for inspection/tests).
|
|
21
|
-
|
|
21
|
+
# Supports optional `limit` and `offset` for batching large sweeps.
|
|
22
|
+
# Returned records are sorted in stable `id` order when batching is used.
|
|
23
|
+
# Use `after_id` for keyset pagination.
|
|
24
|
+
def all(scope:, limit: nil, offset: 0, after_id: nil)
|
|
22
25
|
raise NotImplementedError, "#{self.class} must implement #all"
|
|
23
26
|
end
|
|
24
27
|
|
|
28
|
+
# Optional performance capability: return the subset of requested ids that
|
|
29
|
+
# exists in `scope`, preserving each requested value's representation rather
|
|
30
|
+
# than returning a store-native cast of it. Return at most one requested
|
|
31
|
+
# representation for each persisted record when the store accepts aliases.
|
|
32
|
+
# Callers must fall back
|
|
33
|
+
# to #all for legacy stores that do not expose this method or leave it
|
|
34
|
+
# unimplemented, so adding it does not break custom MemoryStore adapters.
|
|
35
|
+
def existing_ids(scope:, ids:)
|
|
36
|
+
raise NotImplementedError, "#{self.class} does not implement #existing_ids"
|
|
37
|
+
end
|
|
38
|
+
|
|
25
39
|
# Replace the content/embedding of an existing memory. Used by consolidation
|
|
26
|
-
# (UPDATE). Returns the updated Record.
|
|
27
|
-
|
|
40
|
+
# (UPDATE). Returns the updated Record. Raises Engram::Error when the scoped
|
|
41
|
+
# record does not exist or the replacement would move it to another scope.
|
|
42
|
+
def update(scope:, id:, record:)
|
|
28
43
|
raise NotImplementedError, "#{self.class} must implement #update"
|
|
29
44
|
end
|
|
30
45
|
|
|
31
|
-
# Remove a memory by id. Used by consolidation (FORGET).
|
|
32
|
-
|
|
46
|
+
# Remove a memory by id. Used by consolidation (FORGET). Returns the number
|
|
47
|
+
# of affected rows: 1 when deleted, 0 when the scoped record does not exist.
|
|
48
|
+
def delete(scope:, id:)
|
|
33
49
|
raise NotImplementedError, "#{self.class} must implement #delete"
|
|
34
50
|
end
|
|
35
51
|
|
|
36
52
|
# Update the last-accessed timestamp of a memory. Used by recency-aware recall.
|
|
37
|
-
|
|
53
|
+
# Returns the number of affected rows: 1 when touched, 0 when the scoped record
|
|
54
|
+
# does not exist.
|
|
55
|
+
def touch(scope:, id:, at: Time.now)
|
|
38
56
|
raise NotImplementedError, "#{self.class} must implement #touch"
|
|
39
57
|
end
|
|
40
58
|
end
|
|
@@ -2,18 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
module Engram
|
|
4
4
|
module Ports
|
|
5
|
-
#
|
|
6
|
-
#
|
|
5
|
+
# Scoped lifecycle for observation idempotency. Implementations suppress concurrent claims
|
|
6
|
+
# while a lease is live. Lease expiry may permit overlap; this contract does not provide
|
|
7
|
+
# token ownership, fencing, or exactly-once execution.
|
|
8
|
+
# #claim returns a truthy opaque token when the caller may proceed, or nil when suppressed.
|
|
7
9
|
# Implementations: Adapters::InMemoryProcessedTurns, Rails::CacheProcessedTurns.
|
|
8
10
|
module ProcessedTurns
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
raise NotImplementedError, "#{self.class} must implement #seen?"
|
|
11
|
+
def claim(scope:, key:)
|
|
12
|
+
raise NotImplementedError, "#{self.class} must implement #claim"
|
|
12
13
|
end
|
|
13
14
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
def complete(scope:, key:, claim:)
|
|
16
|
+
raise NotImplementedError, "#{self.class} must implement #complete"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def release(scope:, key:, claim:)
|
|
20
|
+
raise NotImplementedError, "#{self.class} must implement #release"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def completed?(scope:, key:)
|
|
24
|
+
raise NotImplementedError, "#{self.class} must implement #completed?"
|
|
17
25
|
end
|
|
18
26
|
end
|
|
19
27
|
end
|