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
@@ -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
- record = prepare(record)
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 = prepare(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
- private
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
- record = record.with(embedding: @embedder.embed(record.content)) if record && record.content != original_content
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 per
11
- # candidate that should result in an action).
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<Record> of candidate memories.
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
- def all(scope:)
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
- def update(id:, record:)
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
- def delete(id:)
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
- def touch(id:, at: Time.now)
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
- # Contract for remembering which turns have already been observed, so observation is
6
- # idempotent across retries and accidental double-calls.
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
- # Has this idempotency key already been processed?
10
- def seen?(key)
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
- # Mark this idempotency key as processed.
15
- def record(key)
16
- raise NotImplementedError, "#{self.class} must implement #record"
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