engram 0.4.0 → 0.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 412e5e9bcb45b4889a24f5b6739a476b84057c3a62aca2bd767856cd4f725e3e
4
- data.tar.gz: 26b9be259f6e937ba91a432bfa12f566c65a721ea49fefb7fa0a057c9fa435af
3
+ metadata.gz: 8a38dfd456e4e89f37a9d4d8949a5bb77b9736d95621e9172ba1db72fb3a5771
4
+ data.tar.gz: 0b537ee13bea0313fd770c529024af45e9ace8fb84f7e49621c2dc7b9d6b888c
5
5
  SHA512:
6
- metadata.gz: fcd14fc54223897ed9d342ee574e279af725507b7653ff7545955f9b15e2815962edb40fdbf1c018fb99813fab84229ad061249fc666f9ff398f7826dede4da0
7
- data.tar.gz: d27dfc3039f3c2e4dcd466be5c1481036dc823e6e1760e966eb8bd76dca4ee22ed2e71eae503ab2355a9696a05b000bf014efc6798e7b344ae4f975db48854f3
6
+ metadata.gz: 768d5d18a9ce04f60db58957127df76fbdc30482df4976d7d04b082af005850ba821ba45a35417c33946668af60331e869cf930e6239409ba10168a453f58665
7
+ data.tar.gz: 7ec7997ba40947deede93b6e970a549bf6caae32eed79f0e4c3922ebecbf05bc1545b5565afc521c9dab6c0f95b27898b3ae9bd9f3f57d9935a48d18e9211322
data/CHANGELOG.md CHANGED
@@ -5,6 +5,63 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
6
  ## [Unreleased]
7
7
 
8
+ ## [0.5.0] - 2026-07-17
9
+
10
+ ### Added
11
+ - Embedding provenance metadata is stored with new memories so applications can detect model
12
+ and dimension drift during store search result validation.
13
+ - `Memory#rebuild_embeddings` and `Engram::UseCases::RebuildEmbeddings` for scoped,
14
+ deterministic rebuilding of stale vectors plus provenance metadata.
15
+ - Added a focused rake task `engram:rebuild_embeddings` with batch control and optional
16
+ forced full-scope rebuild mode for recovery after provider/model changes. The task is
17
+ packaged with the gem and loaded into host Rails apps by the Railtie, where it depends on
18
+ `:environment` so app initializers run first. `STALE_ONLY` accepts `false`, `0`, or `no`.
19
+
20
+ ### Changed
21
+ - `MemoryStore` mutations now require an explicit `scope:` and enforce the `(scope, id)` boundary
22
+ for update, delete, and touch operations. Custom stores must adopt the new scoped signatures;
23
+ `update` returns the updated record or raises `Engram::Error`, while delete and touch return an
24
+ affected-row count.
25
+ - **Breaking (pre-1.0):** the `ProcessedTurns` port migrated from the previous check/mark API
26
+ to `claim`, `complete`, `release`, and `completed?`; custom adapters must implement the new
27
+ claim lifecycle. A successful claim returns a truthy opaque token.
28
+ - Observation idempotency now uses atomic, scope-aware claims with an in-progress lease and
29
+ completed state. `InMemoryProcessedTurns` is thread-safe and releases failures immediately.
30
+ Generic cache release is a no-op until lease expiry because ActiveSupport cache has no atomic
31
+ compare-and-delete; completion never deletes a possibly newer claim. Completed markers
32
+ suppress later calls only for their configured `ttl`. A failed completion write leaves the
33
+ lease to suppress work until expiry, after which already-applied work may replay. `lease_ttl`
34
+ should cover the longest observation while remaining much shorter than `ttl`, and
35
+ `Rails::CacheProcessedTurns` uses atomic `unless_exist` writes with separate claim and
36
+ completion keys. This coordinates concurrent work but does not make multi-decision memory
37
+ persistence and claim completion a crash-proof transaction. Lease expiry may permit overlap,
38
+ so claims do not guarantee ownership, fencing, or exactly-once execution.
39
+ - Added API stability and migration posture documentation for pre-1.0 freeze planning, including public surface boundaries and legacy compatibility points.
40
+ - Store search result validation now raises a clear `Engram::Error` when stored embedding
41
+ metadata or vector dimensions conflict with the active embedder, while legacy records without
42
+ metadata remain searchable when their vector dimensions match the active embedder.
43
+ - Caller metadata keys named `_engram` are now reserved for Engram-owned embedding provenance;
44
+ rename any application metadata stored under that key before adding new memories.
45
+ - `RubyLLMEmbedder` now requests explicitly configured `dimensions:` from the provider (on
46
+ RubyLLM versions that support the option) and validates every returned vector against the
47
+ configured dimensions, raising a clear `Engram::Error` on mismatch. Previously the option
48
+ was recorded in metadata but never sent or checked, so a mismatch surfaced later as opaque
49
+ pgvector insert failures. If you use a model whose native vector size differs from 1536,
50
+ set `dimensions:` to that model's actual output size.
51
+
52
+ ### Fixed
53
+ - `Observe` now raises `Engram::ObservationInProgressError` when a turn is claimed but not
54
+ completed, rather than reporting a successful no-op. `ObserveJob` retries this error with
55
+ backoff that outlasts the default claim lease; direct callers should handle it as retryable.
56
+ - Stale detection in `rebuild_embeddings` now compares against the embedder's declared
57
+ dimensions instead of the stored record's vector length, so a dimensions-only embedder
58
+ change marks existing rows stale. Previously the default stale-only rebuild silently
59
+ skipped every row after such a change. Embedders that do not declare dimensions keep the
60
+ previous record-length comparison.
61
+ - `LLMConsolidator` now ignores malformed decisions and rejects `update`/`forget` targets
62
+ that were not shown to the model, preventing invalid model output from partially applying
63
+ a turn. Cross-scope updates from custom consolidators still raise in `Observe`.
64
+
8
65
  ## [0.4.0] - 2026-06-06
9
66
 
10
67
  ### Added
data/README.md CHANGED
@@ -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`, and `rebuild_embeddings`.
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
@@ -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
@@ -317,9 +362,28 @@ with the old `semantic` kind value.
317
362
 
318
363
  ## Tuning and maintenance
319
364
 
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.
365
+ Observation uses a scope-and-turn claim before extraction. While a claim lease is live and
366
+ the turn has not completed, calls for the same scope and turn raise
367
+ `Engram::ObservationInProgressError` instead of reporting success without doing the work; a
368
+ completed marker suppresses later calls (returning no decisions) until its configured `ttl`
369
+ expires. `ObserveJob` retries `ObservationInProgressError` with polynomial backoff that
370
+ outlasts the default lease. Direct `observe` callers should also treat the error as retryable.
371
+ The in-memory adapter releases failures immediately. Generic Rails cache release
372
+ is deliberately a no-op until lease expiry because ActiveSupport cache has no atomic
373
+ compare-and-delete. In Rails, use a shared cache with atomic `unless_exist` writes for
374
+ cross-process coordination. Set `lease_ttl` longer than the longest expected observation but
375
+ much shorter than the completed-marker `ttl`; after a worker crash (or an overlong
376
+ observation), the lease expires so a retry can proceed.
377
+
378
+ Lease expiry can permit old and new workers to overlap; claims are not fencing tokens or an
379
+ ownership guarantee. Successful work records completion but cannot safely delete a possibly
380
+ newer cache claim. This is idempotency coordination, not crash-proof exactly-once persistence. An observation
381
+ can apply multiple decisions, and the memory writes plus completed marker are not one
382
+ transaction. A crash, cache outage, or lease expiry between those operations can permit a
383
+ retry after some decisions were already written. Applications needing stronger guarantees
384
+ must supply transactional persistence/outbox coordination appropriate to their store.
385
+ If writing the completed marker fails after memory writes succeed, the current lease still
386
+ suppresses retries until it expires; after expiry the turn may replay and repeat those writes.
323
387
 
324
388
  Recall is plain similarity search by default. You can blend in importance and recency:
325
389
 
@@ -338,6 +402,27 @@ Prune memories you no longer need:
338
402
  current_user.memory.forget_stale(older_than: 90 * 24 * 60 * 60, min_importance: 0.7)
339
403
  ```
340
404
 
405
+ If you change embedder configuration (model/provider/dimensions), or if legacy memories were
406
+ written without embedding provenance, run a scoped rebuild to refresh vectors and metadata:
407
+
408
+ ```bash
409
+ bundle exec rake "engram:rebuild_embeddings[user:42]"
410
+ ```
411
+
412
+ The task ships with the gem and is loaded by the Railtie, so it is available inside Rails
413
+ applications and boots the app environment (initializers included) before running. Outside
414
+ Rails, call `memory.rebuild_embeddings` directly.
415
+
416
+ By default, only stale rows are rewritten. Set `STALE_ONLY=false` to rebuild all rows in
417
+ the scope, and `BATCH_SIZE=<n>` to tune write size.
418
+
419
+ The rebuild has no dry-run mode and updates each row as it goes. It assumes the store exposes
420
+ a stable, finite, scope-isolated traversal; malformed adapters that repeat forever cannot be
421
+ made deterministic by the rebuild. Rows without IDs are counted and skipped. Choose a batch
422
+ size that fits provider and database limits, take a backup first, and monitor provider usage,
423
+ errors, and database load. The final summary reports processed, updated, skipped, and failed
424
+ counts; failures also list record IDs and cause the rake task to exit unsuccessfully.
425
+
341
426
  ## Observability
342
427
 
343
428
  When ActiveSupport is loaded, Engram emits `ActiveSupport::Notifications` events for the
@@ -478,8 +563,9 @@ gem unpack engram-*.gem --target /tmp/engram-package-check
478
563
  - v0.1 (done): recall + inject foundation, adapters, Rails + RubyLLM integration.
479
564
  - v0.2 (done): extract and consolidate (ADD / UPDATE / FORGET), background jobs.
480
565
  - 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.
566
+ - v0.4 (done): memory kinds, persistence policy, typed recall filters, safer injection, and observability hooks.
567
+ - v0.5 (in progress): embedding provenance and scoped embedding rebuild operations.
568
+ - later: additional storage backends and larger real-provider eval benchmarks.
483
569
 
484
570
  ## License
485
571
 
@@ -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
@@ -20,37 +20,52 @@ module Engram
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
33
37
  end
34
38
 
35
- def all(scope:)
36
- @records.values.select { |r| r.scope == scope }
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
37
45
  end
38
46
 
39
- def update(id:, record:)
40
- raise Engram::Error, "no memory with id #{id.inspect}" unless @records.key?(id)
47
+ def update(scope:, id:, record:)
48
+ existing = @records[id]
49
+ raise Engram::Error, "no memory with id #{id.inspect} in scope #{scope.inspect}" unless existing&.scope == scope
50
+ raise Engram::Error, "cannot move memory across scopes" unless record.scope == scope
41
51
 
42
52
  record.id = id
43
53
  @records[id] = record
44
54
  end
45
55
 
46
- def delete(id:)
56
+ def delete(scope:, id:)
57
+ return 0 unless @records[id]&.scope == scope
58
+
47
59
  @records.delete(id)
60
+ 1
48
61
  end
49
62
 
50
- def touch(id:, at: Time.now)
63
+ def touch(scope:, id:, at: Time.now)
51
64
  record = @records[id]
52
- record.last_accessed_at = at if record
53
- record
65
+ return 0 unless record&.scope == scope
66
+
67
+ record.last_accessed_at = at
68
+ 1
54
69
  end
55
70
 
56
71
  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|
@@ -28,7 +28,8 @@ module Engram
28
28
  to_record(row)
29
29
  end
30
30
 
31
- def search(embedding:, scope:, limit:, kinds: nil)
31
+ def search(embedding:, scope:, limit:, kinds: nil, embedding_metadata: nil)
32
+ Engram::EmbeddingMetadata.validate_query!(embedding, embedding_metadata)
32
33
  query = model.where(scope: scope)
33
34
  normalized_kinds = normalize_kinds(kinds)
34
35
  query = query.where(kind: normalized_kinds) if normalized_kinds
@@ -37,30 +38,41 @@ module Engram
37
38
  .nearest_neighbors(:embedding, embedding, distance: "cosine")
38
39
  .limit(limit)
39
40
  .map { |row| to_record(row) }
41
+ .tap { |records| validate_query_records!(records, embedding, embedding_metadata) }
40
42
  end
41
43
 
42
- def all(scope:)
43
- model.where(scope: scope).map { |row| to_record(row) }
44
+ def all(scope:, limit: nil, offset: 0, after_id: nil)
45
+ query = model.where(scope: scope).order(:id)
46
+ query = query.where("id > ?", after_id) if after_id
47
+ query = query.limit(limit) if limit
48
+ query = query.offset(offset) if offset > 0
49
+ query.map { |row| to_record(row) }
44
50
  end
45
51
 
46
- def update(id:, record:)
47
- row = model.find(id)
48
- row.update!(
49
- content: record.content,
50
- kind: record.kind.to_s,
51
- importance: record.importance,
52
- metadata: record.metadata,
53
- embedding: record.embedding
54
- )
55
- to_record(row)
52
+ def update(scope:, id:, record:)
53
+ raise Engram::Error, "cannot move memory across scopes" unless record.scope == scope
54
+
55
+ model.transaction do
56
+ row = model.lock.find_by!(id: id, scope: scope)
57
+ row.update!(
58
+ content: record.content,
59
+ kind: record.kind.to_s,
60
+ importance: record.importance,
61
+ metadata: record.metadata,
62
+ embedding: record.embedding
63
+ )
64
+ to_record(row)
65
+ end
66
+ rescue ActiveRecord::RecordNotFound
67
+ raise Engram::Error, "no memory with id #{id.inspect} in scope #{scope.inspect}"
56
68
  end
57
69
 
58
- def delete(id:)
59
- model.where(id: id).delete_all
70
+ def delete(scope:, id:)
71
+ model.where(id: id, scope: scope).delete_all
60
72
  end
61
73
 
62
- def touch(id:, at: Time.now)
63
- model.where(id: id).update_all(last_accessed_at: at)
74
+ def touch(scope:, id:, at: Time.now)
75
+ model.where(id: id, scope: scope).update_all(last_accessed_at: at)
64
76
  end
65
77
 
66
78
  private
@@ -69,6 +81,12 @@ module Engram
69
81
  raise Engram::Error, "memory scope cannot be nil" if scope.nil?
70
82
  end
71
83
 
84
+ def validate_query_records!(records, embedding, embedding_metadata)
85
+ records.each do |record|
86
+ Engram::EmbeddingMetadata.validate_record!(record, embedding, embedding_metadata)
87
+ end
88
+ end
89
+
72
90
  def model
73
91
  @model ||= resolve_default_model
74
92
  end
@@ -10,16 +10,32 @@ module Engram
10
10
  DEFAULT_MODEL = "text-embedding-3-small"
11
11
  DEFAULT_DIMENSIONS = 1536
12
12
 
13
- def initialize(model: DEFAULT_MODEL, dimensions: DEFAULT_DIMENSIONS)
13
+ def initialize(model: DEFAULT_MODEL, dimensions: nil)
14
+ if dimensions && (!dimensions.is_a?(Integer) || !dimensions.positive?)
15
+ raise ArgumentError, "dimensions must be a positive integer"
16
+ end
17
+
14
18
  @model = model
15
- @dimensions = dimensions
19
+ @requested_dimensions = dimensions
20
+ @dimensions = dimensions || DEFAULT_DIMENSIONS
16
21
  end
17
22
 
18
- attr_reader :dimensions
23
+ attr_reader :dimensions, :model
24
+
25
+ def embedding_metadata
26
+ Engram::EmbeddingMetadata.build(
27
+ adapter: self.class.name,
28
+ provider: "ruby_llm",
29
+ model: model,
30
+ dimensions: dimensions
31
+ )
32
+ end
19
33
 
20
34
  def embed(text)
21
35
  ensure_ruby_llm!
22
- RubyLLM.embed(text, model: @model).vectors
36
+ vectors = request_embedding(text)
37
+ validate_dimensions!(vectors)
38
+ vectors
23
39
  end
24
40
 
25
41
  private
@@ -30,6 +46,31 @@ module Engram
30
46
  raise Engram::Error,
31
47
  "RubyLLMEmbedder requires the `ruby_llm` gem. Add it to your Gemfile and configure it."
32
48
  end
49
+
50
+ # Older RubyLLM versions do not accept the dimensions keyword.
51
+ def request_embedding(text)
52
+ if @requested_dimensions && embed_accepts_dimensions?
53
+ RubyLLM.embed(text, model: @model, dimensions: @requested_dimensions).vectors
54
+ else
55
+ RubyLLM.embed(text, model: @model).vectors
56
+ end
57
+ end
58
+
59
+ def embed_accepts_dimensions?
60
+ RubyLLM.method(:embed).parameters.any? do |type, name|
61
+ type == :keyrest || ([:key, :keyreq].include?(type) && name == :dimensions)
62
+ end
63
+ end
64
+
65
+ def validate_dimensions!(vectors)
66
+ length = vectors.respond_to?(:length) ? vectors.length : nil
67
+ return if length == @dimensions
68
+
69
+ raise Engram::Error,
70
+ "embedding model #{@model.inspect} returned #{length ? "#{length}-dimension" : "non-vector"} output " \
71
+ "but the embedder is configured with dimensions: #{@dimensions}; align the dimensions option " \
72
+ "(and your vector column) with the model's output"
73
+ end
33
74
  end
34
75
  end
35
76
  end
@@ -15,7 +15,13 @@ module Engram
15
15
 
16
16
  def reconcile_all(candidates:, scope:)
17
17
  Array(candidates).map do |candidate|
18
- nearest = @store.search(embedding: candidate.embedding, scope: scope, limit: 1).first
18
+ nearest = Engram::EmbeddingMetadata.search(
19
+ @store,
20
+ embedding: candidate.embedding,
21
+ embedding_metadata: Engram::EmbeddingMetadata.extract(candidate.metadata),
22
+ scope: scope,
23
+ limit: 1
24
+ ).first
19
25
  similarity = nearest ? Engram::Math.cosine_similarity(candidate.embedding, nearest.embedding) : 0.0
20
26
 
21
27
  if nearest && similarity >= @similarity_threshold
@@ -56,23 +56,35 @@ module Engram
56
56
  candidates = Array(candidates)
57
57
  return [] if candidates.empty?
58
58
 
59
+ neighbors = neighbor_map(candidates, scope)
59
60
  result = @completion.complete(
60
61
  system: SYSTEM,
61
- user: JSON.generate(payload(candidates, scope)),
62
+ user: JSON.generate(payload(candidates, neighbors)),
62
63
  schema: SCHEMA
63
64
  )
64
- map_decisions(decisions(result), candidates)
65
+ map_decisions(decisions(result), candidates, neighbors)
65
66
  end
66
67
 
67
68
  private
68
69
 
69
- def payload(candidates, scope)
70
+ def neighbor_map(candidates, scope)
71
+ candidates.map do |candidate|
72
+ Engram::EmbeddingMetadata.search(
73
+ @store,
74
+ embedding: candidate.embedding,
75
+ embedding_metadata: Engram::EmbeddingMetadata.extract(candidate.metadata),
76
+ scope: scope,
77
+ limit: @neighbors
78
+ )
79
+ end
80
+ end
81
+
82
+ def payload(candidates, neighbors)
70
83
  items = candidates.each_with_index.map do |candidate, index|
71
- existing = @store.search(embedding: candidate.embedding, scope: scope, limit: @neighbors)
72
84
  {
73
85
  index: index,
74
86
  candidate: candidate.content,
75
- existing: existing.map { |r| {id: r.id, content: r.content} }
87
+ existing: neighbors[index].map { |r| {id: r.id, content: r.content} }
76
88
  }
77
89
  end
78
90
  {candidates: items}
@@ -81,19 +93,34 @@ module Engram
81
93
  def decisions(result)
82
94
  return [] unless result.is_a?(Hash)
83
95
 
84
- result["decisions"] || result[:decisions] || []
96
+ decisions = result["decisions"] || result[:decisions] || []
97
+ decisions.is_a?(Array) ? decisions : []
85
98
  end
86
99
 
87
- def map_decisions(raw, candidates)
100
+ def map_decisions(raw, candidates, neighbors)
101
+ seen = Set.new
88
102
  raw.filter_map do |decision|
103
+ next unless decision.is_a?(Hash)
104
+
89
105
  decision = decision.transform_keys(&:to_s)
90
106
  index = decision["index"]
91
- next unless index && candidates[index]
107
+ next unless index.is_a?(Integer) && index >= 0 && candidates[index]
108
+ next if seen.include?(index)
109
+
110
+ action = (decision["action"] || "noop").to_s
111
+ next unless Engram::Decision::ACTIONS.include?(action.to_sym)
112
+
113
+ target_id = decision["target_id"]
114
+ if %w[update forget].include?(action)
115
+ next if target_id.nil?
116
+ next unless neighbors[index].any? { |neighbor| neighbor.id == target_id }
117
+ end
92
118
 
119
+ seen.add(index)
93
120
  Engram::Decision.new(
94
- action: (decision["action"] || "noop").to_sym,
121
+ action: action.to_sym,
95
122
  candidate: candidates[index],
96
- target_id: decision["target_id"],
123
+ target_id: target_id,
97
124
  reason: decision["reason"]
98
125
  )
99
126
  end