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
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Loaded by the Railtie in Rails apps (where :environment boots the app and its
|
|
4
|
+
# initializers) and by the gem's own Rakefile (which defines a no-op :environment).
|
|
5
|
+
namespace :engram do
|
|
6
|
+
desc "Rebuild embeddings in a memory scope. Usage: bundle exec rake 'engram:rebuild_embeddings[user:1]'"
|
|
7
|
+
task :rebuild_embeddings, [:scope] => :environment do |_, args|
|
|
8
|
+
scope = args[:scope]
|
|
9
|
+
raise ArgumentError, "rebuild_embeddings requires a scope argument" if scope.to_s.empty?
|
|
10
|
+
|
|
11
|
+
stale_only = !%w[false 0 no].include?(ENV.fetch("STALE_ONLY", "true").downcase)
|
|
12
|
+
batch_size = Integer(ENV.fetch("BATCH_SIZE", "100"))
|
|
13
|
+
raise ArgumentError, "BATCH_SIZE must be greater than 0" unless batch_size.positive?
|
|
14
|
+
|
|
15
|
+
result = Engram::UseCases::RebuildEmbeddings.new(
|
|
16
|
+
store: Engram.config.store,
|
|
17
|
+
embedder: Engram.config.embedder
|
|
18
|
+
).call(scope: scope, stale_only: stale_only, batch_size: batch_size)
|
|
19
|
+
|
|
20
|
+
puts "scope=#{result[:scope]} processed=#{result[:processed]} updated=#{result[:updated]} skipped=#{result[:skipped]} failed=#{result[:failed]}"
|
|
21
|
+
|
|
22
|
+
if result[:failed] > 0
|
|
23
|
+
abort "failed_ids=#{result[:failed_ids].join(",")}"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
data/lib/engram/railtie.rb
CHANGED
data/lib/engram/record.rb
CHANGED
|
@@ -8,6 +8,10 @@ module Engram
|
|
|
8
8
|
# `kind` is a memory type (fact / preference / instruction / episodic). The legacy
|
|
9
9
|
# `semantic` kind is normalized to `fact` for compatibility with pre-1.0 records.
|
|
10
10
|
class Record
|
|
11
|
+
STATE_READERS = %i[
|
|
12
|
+
id content scope embedding kind importance metadata created_at last_accessed_at
|
|
13
|
+
].freeze
|
|
14
|
+
|
|
11
15
|
attr_accessor :id, :last_accessed_at
|
|
12
16
|
attr_reader :content, :embedding, :scope, :kind, :importance, :metadata,
|
|
13
17
|
:created_at
|
|
@@ -29,12 +33,15 @@ module Engram
|
|
|
29
33
|
self.class.new(**to_h.merge(attributes))
|
|
30
34
|
end
|
|
31
35
|
|
|
36
|
+
# Return structured supporting-source metadata when this record carries a
|
|
37
|
+
# provenance schema understood by the installed Engram version.
|
|
38
|
+
# Legacy, malformed, and future-schema records remain readable and return nil.
|
|
39
|
+
def provenance
|
|
40
|
+
Engram::Provenance.extract(metadata)
|
|
41
|
+
end
|
|
42
|
+
|
|
32
43
|
def to_h
|
|
33
|
-
{
|
|
34
|
-
id: id, content: content, scope: scope, embedding: embedding, kind: kind,
|
|
35
|
-
importance: importance, metadata: metadata,
|
|
36
|
-
created_at: created_at, last_accessed_at: last_accessed_at
|
|
37
|
-
}
|
|
44
|
+
STATE_READERS.to_h { |reader| [reader, public_send(reader)] }
|
|
38
45
|
end
|
|
39
46
|
end
|
|
40
47
|
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Engram
|
|
4
|
+
# Collision-preserving operations for schemas sharing Engram's metadata namespace.
|
|
5
|
+
module ReservedMetadata
|
|
6
|
+
KEY = "_engram"
|
|
7
|
+
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def attach(metadata, schema_key, value, namespace_description: "metadata")
|
|
11
|
+
metadata = (metadata || {}).dup
|
|
12
|
+
reserved_values = [metadata.delete(KEY), metadata.delete(:_engram)].compact
|
|
13
|
+
unless reserved_values.all? { |reserved| reserved.is_a?(Hash) }
|
|
14
|
+
raise Engram::Error, "metadata key #{KEY.inspect} is reserved for Engram #{namespace_description}"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
reserved = reserved_values.reduce({}) do |merged, reserved_value|
|
|
18
|
+
siblings = reserved_value.reject { |key, _nested| key.to_s == schema_key }
|
|
19
|
+
merge(merged, normalize(siblings))
|
|
20
|
+
end
|
|
21
|
+
metadata.merge(KEY => reserved.merge(schema_key => normalize(value)))
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def normalize(value, path = [])
|
|
25
|
+
return value.map { |nested| normalize(nested, path) } if value.is_a?(Array)
|
|
26
|
+
return value unless value.is_a?(Hash)
|
|
27
|
+
|
|
28
|
+
value.each_with_object({}) do |(key, nested), normalized|
|
|
29
|
+
string_key = key.to_s
|
|
30
|
+
normalized_value = normalize(nested, path + [string_key])
|
|
31
|
+
normalized[string_key] = if normalized.key?(string_key)
|
|
32
|
+
merge_value(normalized[string_key], normalized_value, path + [string_key])
|
|
33
|
+
else
|
|
34
|
+
normalized_value
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def merge(left, right, path = [])
|
|
40
|
+
right.each_with_object(left.dup) do |(key, value), merged|
|
|
41
|
+
merged[key] = merged.key?(key) ? merge_value(merged[key], value, path + [key]) : value
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def merge_value(left, right, path)
|
|
46
|
+
return merge(left, right, path) if left.is_a?(Hash) && right.is_a?(Hash)
|
|
47
|
+
return left if left == right
|
|
48
|
+
|
|
49
|
+
raise Engram::Error, "conflicting reserved metadata at #{path.join(".")}"
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -20,8 +20,12 @@ module Engram
|
|
|
20
20
|
timestamp && timestamp < cutoff && record.importance.to_f < min_importance
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
-
stale.
|
|
24
|
-
|
|
23
|
+
stale.select do |record|
|
|
24
|
+
next false unless record.id
|
|
25
|
+
|
|
26
|
+
deleted = @store.delete(scope: scope, id: record.id)
|
|
27
|
+
deleted && deleted != 0
|
|
28
|
+
end
|
|
25
29
|
end
|
|
26
30
|
end
|
|
27
31
|
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Engram
|
|
4
|
+
module UseCases
|
|
5
|
+
# Count records in a scope by their weakest understood source alignment.
|
|
6
|
+
class GroundingReport
|
|
7
|
+
ALIGNMENT_RANK = Engram::Provenance::ALIGNMENTS.each_with_index.to_h.freeze
|
|
8
|
+
private_constant :ALIGNMENT_RANK
|
|
9
|
+
|
|
10
|
+
def initialize(store:)
|
|
11
|
+
@store = store
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call(scope:)
|
|
15
|
+
counts = {
|
|
16
|
+
exact: 0,
|
|
17
|
+
normalized: 0,
|
|
18
|
+
inferred: 0,
|
|
19
|
+
ungrounded: 0,
|
|
20
|
+
unattributed: 0,
|
|
21
|
+
total: 0
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
@store.all(scope: scope).each do |record|
|
|
25
|
+
provenance = record.provenance
|
|
26
|
+
alignment = provenance && weakest_alignment(provenance)
|
|
27
|
+
counts[alignment || :unattributed] += 1
|
|
28
|
+
counts[:total] += 1
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
counts.freeze
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def weakest_alignment(provenance)
|
|
37
|
+
alignments = provenance.sources.map(&:alignment)
|
|
38
|
+
return unless alignments.all? { |alignment| ALIGNMENT_RANK.key?(alignment) }
|
|
39
|
+
|
|
40
|
+
alignments.max_by { |alignment| ALIGNMENT_RANK.fetch(alignment) }
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -26,69 +26,343 @@ module Engram
|
|
|
26
26
|
idempotency_key_present: !idempotency_key.nil?
|
|
27
27
|
)
|
|
28
28
|
Engram::Instrumentation.instrument("observe", payload) do
|
|
29
|
-
|
|
29
|
+
claim = acquire_claim(scope, idempotency_key)
|
|
30
|
+
if idempotency_key && @processed_turns && !claim
|
|
31
|
+
unless @processed_turns.completed?(scope: scope, key: idempotency_key)
|
|
32
|
+
# A live, incomplete lease is retryable, not a completed duplicate.
|
|
33
|
+
raise Engram::ObservationInProgressError,
|
|
34
|
+
"observation for this turn is claimed but not completed; retry after the claim lease expires"
|
|
35
|
+
end
|
|
36
|
+
|
|
30
37
|
payload[:skipped] = true
|
|
31
38
|
payload[:candidate_count] = 0
|
|
32
39
|
payload[:decision_count] = 0
|
|
33
40
|
next []
|
|
34
41
|
end
|
|
35
42
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
+
begin
|
|
44
|
+
candidates = extract(messages: messages, scope: scope)
|
|
45
|
+
payload[:candidate_count] = candidates.size
|
|
46
|
+
if candidates.empty?
|
|
47
|
+
complete_claim(scope, idempotency_key, claim)
|
|
48
|
+
payload[:decision_count] = 0
|
|
49
|
+
next []
|
|
50
|
+
end
|
|
43
51
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
52
|
+
decisions, candidate_budget, detached_by_candidate = consolidate(candidates: candidates, scope: scope)
|
|
53
|
+
operations = preflight(decisions, candidate_budget, detached_by_candidate, scope)
|
|
54
|
+
applied_decisions = operations.filter_map { |operation| apply(operation, scope) }
|
|
55
|
+
payload[:decision_count] = applied_decisions.size
|
|
56
|
+
payload[:decision_actions] = applied_decisions.map { |decision| decision.action.to_s }
|
|
57
|
+
complete_claim(scope, idempotency_key, claim)
|
|
58
|
+
applied_decisions
|
|
59
|
+
rescue
|
|
60
|
+
release_claim(scope, idempotency_key, claim)
|
|
61
|
+
raise
|
|
62
|
+
end
|
|
50
63
|
end
|
|
51
64
|
end
|
|
52
65
|
|
|
53
66
|
private
|
|
54
67
|
|
|
55
|
-
def
|
|
56
|
-
|
|
68
|
+
def acquire_claim(scope, key)
|
|
69
|
+
@processed_turns.claim(scope: scope, key: key) if key && @processed_turns
|
|
57
70
|
end
|
|
58
71
|
|
|
59
|
-
def
|
|
60
|
-
@processed_turns.
|
|
72
|
+
def complete_claim(scope, key, claim)
|
|
73
|
+
@processed_turns.complete(scope: scope, key: key, claim: claim) if claim
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def release_claim(scope, key, claim)
|
|
77
|
+
@processed_turns.release(scope: scope, key: key, claim: claim) if claim
|
|
61
78
|
end
|
|
62
79
|
|
|
63
80
|
def extract(messages:, scope:)
|
|
64
81
|
payload = Engram::Instrumentation.payload(scope: scope, store: @store, message_count: messages.size)
|
|
65
82
|
Engram::Instrumentation.instrument("extract", payload) do
|
|
66
|
-
candidates = @extractor.extract(messages: messages, scope: scope)
|
|
83
|
+
candidates = normalize_extractions(@extractor.extract(messages: messages, scope: scope))
|
|
67
84
|
payload[:candidate_count] = candidates.size
|
|
68
85
|
candidates
|
|
69
86
|
end
|
|
70
87
|
end
|
|
71
88
|
|
|
89
|
+
def normalize_extractions(results)
|
|
90
|
+
unless core_array?(results)
|
|
91
|
+
raise Engram::Error, "extractor must return an Array containing only Engram::Record or Engram::Extraction values"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
core_array_map(results) do |result|
|
|
95
|
+
candidate = case result
|
|
96
|
+
when Engram::Record then normalize_record(result)
|
|
97
|
+
when Engram::Extraction then normalize_record(result.to_record)
|
|
98
|
+
else
|
|
99
|
+
raise Engram::Error, "extractor must return an Array containing only Engram::Record or Engram::Extraction values"
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
raise Engram::Error, "observation candidates must not have an id" unless candidate.id.nil?
|
|
103
|
+
|
|
104
|
+
candidate
|
|
105
|
+
end
|
|
106
|
+
rescue Engram::Internal::CandidateIntegrity::Error => error
|
|
107
|
+
raise Engram::Error, error.message
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def normalize_record(record)
|
|
111
|
+
if Object.instance_method(:instance_of?).bind_call(record, Engram::Record)
|
|
112
|
+
candidate_integrity.validate!(record)
|
|
113
|
+
return record
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
candidate_integrity.detach(record)
|
|
117
|
+
rescue Engram::Internal::CandidateIntegrity::Error => error
|
|
118
|
+
raise Engram::Error, error.message
|
|
119
|
+
end
|
|
120
|
+
|
|
72
121
|
def consolidate(candidates:, scope:)
|
|
73
122
|
payload = Engram::Instrumentation.payload(scope: scope, store: @store, candidate_count: candidates.size)
|
|
74
123
|
Engram::Instrumentation.instrument("consolidate", payload) do
|
|
75
|
-
|
|
124
|
+
candidate_snapshot = snapshot_candidates(candidates)
|
|
125
|
+
detached_candidates = detach_candidates(candidates)
|
|
126
|
+
candidate_budget, detached_by_candidate = candidate_context(candidates, detached_candidates)
|
|
127
|
+
raw_decisions = @consolidator.reconcile_all(candidates: candidates, scope: scope)
|
|
128
|
+
decisions = canonicalize_decisions(raw_decisions)
|
|
129
|
+
verify_candidates!(candidates, candidate_snapshot)
|
|
130
|
+
|
|
76
131
|
payload[:decision_count] = decisions.size
|
|
77
132
|
payload[:decision_actions] = decisions.map { |decision| decision.action.to_s }
|
|
78
|
-
decisions
|
|
133
|
+
[decisions, candidate_budget, detached_by_candidate]
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def snapshot_candidates(candidates)
|
|
138
|
+
candidate_integrity.snapshot(candidates)
|
|
139
|
+
rescue Engram::Internal::CandidateIntegrity::Error => error
|
|
140
|
+
raise Engram::Error, error.message
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def verify_candidates!(candidates, snapshot)
|
|
144
|
+
candidate_integrity.verify!(candidates, snapshot)
|
|
145
|
+
rescue Engram::Internal::CandidateIntegrity::Error => error
|
|
146
|
+
raise Engram::Error, error.message
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def detach_candidates(candidates)
|
|
150
|
+
candidates.map { |candidate| candidate_integrity.detach(candidate) }
|
|
151
|
+
rescue Engram::Internal::CandidateIntegrity::Error => error
|
|
152
|
+
raise Engram::Error, error.message
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def candidate_context(candidates, detached_candidates)
|
|
156
|
+
candidate_budget = Hash.new(0).compare_by_identity
|
|
157
|
+
detached_by_candidate = {}.compare_by_identity
|
|
158
|
+
candidates.zip(detached_candidates) do |candidate, detached|
|
|
159
|
+
candidate_budget[candidate] += 1
|
|
160
|
+
(detached_by_candidate[candidate] ||= []) << detached
|
|
161
|
+
end
|
|
162
|
+
[candidate_budget, detached_by_candidate]
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def candidate_integrity
|
|
166
|
+
@candidate_integrity ||= Engram::Internal::CandidateIntegrity.new
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def canonicalize_decisions(raw_decisions)
|
|
170
|
+
unless core_array?(raw_decisions)
|
|
171
|
+
raise Engram::Error, "consolidator must return an Array of Engram::Decision values"
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
core_array_map(raw_decisions) do |raw_decision|
|
|
175
|
+
unless Object.instance_method(:is_a?).bind_call(raw_decision, Engram::Decision)
|
|
176
|
+
raise Engram::Error, "consolidator must return an Array of Engram::Decision values"
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
action = raw_decision.action
|
|
180
|
+
candidate = raw_decision.candidate
|
|
181
|
+
target_id = canonical_target_id(raw_decision.target_id)
|
|
182
|
+
reason = raw_decision.reason
|
|
183
|
+
unless Engram::Decision::ACTIONS.include?(action)
|
|
184
|
+
raise Engram::Error, "unsupported decision action #{action.inspect}"
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
Engram::Decision.new(action: action, candidate: candidate, target_id: target_id, reason: reason)
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Array subclasses are supported, but checks and traversal at an untrusted
|
|
192
|
+
# return boundary must not dispatch through singleton or subclass behavior.
|
|
193
|
+
def core_array?(value)
|
|
194
|
+
Object.instance_method(:is_a?).bind_call(value, Array)
|
|
195
|
+
rescue TypeError
|
|
196
|
+
false
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def core_array_map(values)
|
|
200
|
+
mapped = []
|
|
201
|
+
Array.instance_method(:each).bind_call(values) { |value| mapped << yield(value) }
|
|
202
|
+
mapped
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def canonical_target_id(target_id)
|
|
206
|
+
return if BasicObject.instance_method(:equal?).bind_call(target_id, nil)
|
|
207
|
+
|
|
208
|
+
target_class = Object.instance_method(:class).bind_call(target_id)
|
|
209
|
+
valid_class = target_class.equal?(Integer) || target_class.equal?(String)
|
|
210
|
+
valid_state = Object.instance_method(:instance_variables).bind_call(target_id).empty?
|
|
211
|
+
unless valid_class && valid_state
|
|
212
|
+
raise Engram::Error, "decision target_id must be a plain String or Integer"
|
|
79
213
|
end
|
|
214
|
+
|
|
215
|
+
return target_id if target_class.equal?(Integer)
|
|
216
|
+
|
|
217
|
+
if custom_behavior?(target_id, target_class)
|
|
218
|
+
raise Engram::Error, "decision target_id must be a plain String or Integer"
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
String.instance_method(:dup).bind_call(target_id)
|
|
222
|
+
rescue TypeError
|
|
223
|
+
raise Engram::Error, "decision target_id must be a plain String or Integer"
|
|
80
224
|
end
|
|
81
225
|
|
|
82
|
-
def
|
|
226
|
+
def custom_behavior?(value, value_class)
|
|
227
|
+
singleton_class = Object.instance_method(:singleton_class).bind_call(value)
|
|
228
|
+
return false if singleton_class.equal?(value_class)
|
|
229
|
+
|
|
230
|
+
method_visibilities = %i[
|
|
231
|
+
public_instance_methods protected_instance_methods private_instance_methods
|
|
232
|
+
]
|
|
233
|
+
return true if method_visibilities.any? do |visibility|
|
|
234
|
+
Module.instance_method(visibility).bind_call(singleton_class, false).any?
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
singleton_ancestors = Module.instance_method(:ancestors).bind_call(singleton_class)
|
|
238
|
+
class_ancestors = Module.instance_method(:ancestors).bind_call(value_class)
|
|
239
|
+
return true unless singleton_ancestors.length == class_ancestors.length + 1 &&
|
|
240
|
+
class_ancestors.each_with_index.all? { |ancestor, index| singleton_ancestors[index + 1].equal?(ancestor) }
|
|
241
|
+
|
|
242
|
+
method_visibilities.any? do |visibility|
|
|
243
|
+
singleton_methods = Module.instance_method(visibility).bind_call(singleton_class, true)
|
|
244
|
+
class_methods = Module.instance_method(visibility).bind_call(value_class, true)
|
|
245
|
+
singleton_methods.length != class_methods.length ||
|
|
246
|
+
singleton_methods.any? { |method_name| !class_methods.include?(method_name) }
|
|
247
|
+
end
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def preflight(decisions, candidate_budget, detached_by_candidate, scope)
|
|
251
|
+
decision_counts = Hash.new(0).compare_by_identity
|
|
252
|
+
safe_decisions = []
|
|
253
|
+
|
|
254
|
+
decisions.each do |decision|
|
|
255
|
+
unless decision.is_a?(Engram::Decision)
|
|
256
|
+
raise Engram::Error, "consolidator must return an Array of Engram::Decision values"
|
|
257
|
+
end
|
|
258
|
+
unless Engram::Decision::ACTIONS.include?(decision.action)
|
|
259
|
+
raise Engram::Error, "unsupported decision action #{decision.action.inspect}"
|
|
260
|
+
end
|
|
261
|
+
unless Object.instance_method(:is_a?).bind_call(decision.candidate, Engram::Record)
|
|
262
|
+
message = if decision.action == :forget
|
|
263
|
+
"forget decision candidate must be an Engram::Record"
|
|
264
|
+
else
|
|
265
|
+
"decision candidate must be an Engram::Record"
|
|
266
|
+
end
|
|
267
|
+
raise Engram::Error, message
|
|
268
|
+
end
|
|
269
|
+
unless candidate_budget.key?(decision.candidate)
|
|
270
|
+
raise Engram::Error, "decision must reference the actual candidate supplied to the consolidator"
|
|
271
|
+
end
|
|
272
|
+
decision_counts[decision.candidate] += 1
|
|
273
|
+
if decision_counts[decision.candidate] > candidate_budget.fetch(decision.candidate)
|
|
274
|
+
raise Engram::Error,
|
|
275
|
+
"consolidator must return no more than one decision per candidate occurrence; " \
|
|
276
|
+
"multiple decisions reference the same candidate"
|
|
277
|
+
end
|
|
278
|
+
detached_candidate = detached_by_candidate.fetch(decision.candidate).fetch(
|
|
279
|
+
decision_counts.fetch(decision.candidate) - 1
|
|
280
|
+
)
|
|
281
|
+
unless Engram::Internal::Scope.record_matches?(detached_candidate, scope)
|
|
282
|
+
raise Engram::Error, "cannot move memory across scopes"
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
if %i[update forget].include?(decision.action) && !decision.target_id
|
|
286
|
+
raise Engram::Error, "#{decision.action} decision requires a target_id"
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
if %i[add update forget].include?(decision.action)
|
|
290
|
+
Engram::Provenance.extract_for_persistence(detached_candidate.metadata)
|
|
291
|
+
end
|
|
292
|
+
safe_decisions << Engram::Decision.new(action: decision.action, candidate: detached_candidate,
|
|
293
|
+
target_id: decision.target_id, reason: decision.reason)
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
preflight_destructive_targets!(safe_decisions, scope)
|
|
297
|
+
decisions.zip(safe_decisions).map do |result_decision, safe_decision|
|
|
298
|
+
prepare_operation(safe_decision, scope, result_decision)
|
|
299
|
+
end
|
|
300
|
+
end
|
|
301
|
+
|
|
302
|
+
def preflight_destructive_targets!(decisions, scope)
|
|
303
|
+
target_ids = decisions.filter_map do |decision|
|
|
304
|
+
decision.target_id if %i[update forget].include?(decision.action)
|
|
305
|
+
end
|
|
306
|
+
return if target_ids.empty?
|
|
307
|
+
|
|
308
|
+
duplicate_id = target_ids.tally.find { |_, count| count > 1 }&.first
|
|
309
|
+
if duplicate_id
|
|
310
|
+
raise Engram::Error, "multiple decisions target memory #{duplicate_id.inspect}"
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
existing_ids = scoped_existing_ids(scope, target_ids)
|
|
314
|
+
existing_id_lookup = existing_ids.each_with_object({}) { |id, lookup| lookup[id] = true }
|
|
315
|
+
missing_id = target_ids.find { |target_id| !existing_id_lookup[target_id] }
|
|
316
|
+
if missing_id
|
|
317
|
+
raise Engram::Error, "no memory with id #{missing_id.inspect} in scope #{scope.inspect}"
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def scoped_existing_ids(scope, target_ids)
|
|
322
|
+
if @store.respond_to?(:existing_ids)
|
|
323
|
+
begin
|
|
324
|
+
return Array(@store.existing_ids(scope: scope, ids: target_ids))
|
|
325
|
+
rescue NotImplementedError
|
|
326
|
+
# Optional capability: legacy adapters may inherit the default stub.
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
@store.all(scope: scope).map(&:id)
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
def prepare_operation(decision, scope, result_decision)
|
|
334
|
+
prepared = case decision.action
|
|
335
|
+
when :add
|
|
336
|
+
persistence.prepare(decision.candidate)
|
|
337
|
+
when :update
|
|
338
|
+
persistence.prepare(decision.candidate) if decision.target_id
|
|
339
|
+
when :forget
|
|
340
|
+
persistence.allowed?(decision.candidate) if decision.target_id
|
|
341
|
+
end
|
|
342
|
+
|
|
343
|
+
if %i[add update].include?(decision.action) && prepared &&
|
|
344
|
+
!Engram::Internal::Scope.record_matches?(prepared, scope)
|
|
345
|
+
raise Engram::Error, "cannot move memory across scopes"
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
[result_decision, decision, prepared]
|
|
349
|
+
end
|
|
350
|
+
|
|
351
|
+
def apply(operation, scope)
|
|
352
|
+
result_decision, decision, prepared = operation
|
|
83
353
|
case decision.action
|
|
84
354
|
when :add
|
|
85
|
-
|
|
355
|
+
result_decision if prepared && persistence.add_prepared(prepared, scope: scope)
|
|
86
356
|
when :update
|
|
87
|
-
if decision.target_id &&
|
|
88
|
-
|
|
357
|
+
if decision.target_id && prepared &&
|
|
358
|
+
persistence.update_prepared(scope: scope, id: decision.target_id, record: prepared)
|
|
359
|
+
result_decision
|
|
89
360
|
end
|
|
90
361
|
when :forget
|
|
91
|
-
|
|
362
|
+
if decision.target_id && prepared
|
|
363
|
+
deleted = @store.delete(scope: scope, id: decision.target_id)
|
|
364
|
+
result_decision if deleted && deleted != 0
|
|
365
|
+
end
|
|
92
366
|
when :noop
|
|
93
367
|
nil
|
|
94
368
|
end
|