llmemory 0.2.11 → 0.3.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 (70) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +23 -0
  3. data/lib/generators/llmemory/install/templates/create_llmemory_tables.rb +71 -0
  4. data/lib/generators/llmemory/upgrade_zero_mem/templates/create_llmemory_zero_mem_tables.rb +77 -0
  5. data/lib/generators/llmemory/upgrade_zero_mem/upgrade_zero_mem_generator.rb +19 -0
  6. data/lib/llmemory/cli/commands/zero_mem.rb +107 -0
  7. data/lib/llmemory/cli.rb +3 -0
  8. data/lib/llmemory/configuration.rb +59 -1
  9. data/lib/llmemory/instrumentation.rb +10 -1
  10. data/lib/llmemory/maintenance/cognitive_pass.rb +29 -1
  11. data/lib/llmemory/maintenance/zero_mem_ttl.rb +26 -0
  12. data/lib/llmemory/maintenance.rb +1 -0
  13. data/lib/llmemory/mcp/server.rb +2 -0
  14. data/lib/llmemory/mcp/store_helpers.rb +16 -0
  15. data/lib/llmemory/mcp/tools/memory_add_message.rb +17 -4
  16. data/lib/llmemory/mcp/tools/memory_consolidate.rb +9 -4
  17. data/lib/llmemory/mcp/tools/memory_maintain.rb +6 -1
  18. data/lib/llmemory/mcp/tools/memory_retrieve.rb +3 -1
  19. data/lib/llmemory/mcp/tools/memory_retrieve_evidence.rb +70 -0
  20. data/lib/llmemory/mcp/tools/memory_stats.rb +22 -0
  21. data/lib/llmemory/memory.rb +257 -9
  22. data/lib/llmemory/retrieval/bm25_scorer.rb +5 -0
  23. data/lib/llmemory/tokenizer.rb +18 -8
  24. data/lib/llmemory/vector_store/memory_store.rb +23 -1
  25. data/lib/llmemory/vector_store/openai_embeddings.rb +47 -21
  26. data/lib/llmemory/version.rb +1 -1
  27. data/lib/llmemory/zero_mem/answer_calibrator.rb +63 -0
  28. data/lib/llmemory/zero_mem/backfill.rb +43 -0
  29. data/lib/llmemory/zero_mem/budget_assembler.rb +62 -0
  30. data/lib/llmemory/zero_mem/circuit_breaker.rb +37 -0
  31. data/lib/llmemory/zero_mem/embedding_provider.rb +73 -0
  32. data/lib/llmemory/zero_mem/engine.rb +247 -0
  33. data/lib/llmemory/zero_mem/entity.rb +31 -0
  34. data/lib/llmemory/zero_mem/entity_extractor.rb +23 -0
  35. data/lib/llmemory/zero_mem/entity_index.rb +65 -0
  36. data/lib/llmemory/zero_mem/entity_mention.rb +35 -0
  37. data/lib/llmemory/zero_mem/errors.rb +7 -0
  38. data/lib/llmemory/zero_mem/evidence.rb +43 -0
  39. data/lib/llmemory/zero_mem/evidence_calibrator.rb +158 -0
  40. data/lib/llmemory/zero_mem/evidence_closure.rb +98 -0
  41. data/lib/llmemory/zero_mem/evidence_fusion.rb +57 -0
  42. data/lib/llmemory/zero_mem/evidence_set.rb +54 -0
  43. data/lib/llmemory/zero_mem/extractors/heuristic.rb +44 -0
  44. data/lib/llmemory/zero_mem/extractors/http.rb +58 -0
  45. data/lib/llmemory/zero_mem/graph_retriever.rb +107 -0
  46. data/lib/llmemory/zero_mem/hierarchy_builder.rb +123 -0
  47. data/lib/llmemory/zero_mem/hierarchy_retriever.rb +133 -0
  48. data/lib/llmemory/zero_mem/indexer.rb +73 -0
  49. data/lib/llmemory/zero_mem/mode.rb +30 -0
  50. data/lib/llmemory/zero_mem/pagerank.rb +42 -0
  51. data/lib/llmemory/zero_mem/query_profile.rb +48 -0
  52. data/lib/llmemory/zero_mem/query_profiler.rb +138 -0
  53. data/lib/llmemory/zero_mem/repair.rb +31 -0
  54. data/lib/llmemory/zero_mem/router.rb +69 -0
  55. data/lib/llmemory/zero_mem/sidecar_client.rb +82 -0
  56. data/lib/llmemory/zero_mem/storage.rb +124 -0
  57. data/lib/llmemory/zero_mem/storages/active_record.rb +311 -0
  58. data/lib/llmemory/zero_mem/storages/active_record_models.rb +21 -0
  59. data/lib/llmemory/zero_mem/storages/file.rb +67 -0
  60. data/lib/llmemory/zero_mem/storages/memory.rb +294 -0
  61. data/lib/llmemory/zero_mem/storages/postgres.rb +85 -0
  62. data/lib/llmemory/zero_mem/storages/snapshot_codec.rb +180 -0
  63. data/lib/llmemory/zero_mem/storages.rb +43 -0
  64. data/lib/llmemory/zero_mem/trace.rb +100 -0
  65. data/lib/llmemory/zero_mem/trace_state_link.rb +64 -0
  66. data/lib/llmemory/zero_mem/trace_unit.rb +83 -0
  67. data/lib/llmemory/zero_mem.rb +42 -0
  68. data/lib/llmemory.rb +7 -0
  69. data/lib/tasks/llmemory.rake +52 -0
  70. metadata +48 -2
@@ -14,37 +14,59 @@ module Llmemory
14
14
  DEFAULT_MODEL = "text-embedding-3-small"
15
15
  DEFAULT_DIMS = 1536
16
16
 
17
- attr_reader :last_usage
17
+ attr_reader :last_usage, :model
18
18
 
19
- def initialize(api_key: nil, model: nil, base_url: nil)
19
+ def initialize(api_key: nil, model: nil, base_url: nil, dimensions: nil)
20
20
  @api_key = api_key || Llmemory.configuration.llm_api_key
21
21
  @model = model || DEFAULT_MODEL
22
+ @dimensions = dimensions || DEFAULT_DIMS
22
23
  @base_url = base_url || Llmemory.configuration.llm_base_url || "https://api.openai.com/v1"
23
24
  @cache = {}
24
25
  @cache_order = []
25
26
  @last_usage = Llmemory::LLM::Usage.zero
26
27
  end
27
28
 
29
+ def dimensions
30
+ @dimensions
31
+ end
32
+
33
+ def local?
34
+ false
35
+ end
36
+
28
37
  def embed(text)
29
- return Array.new(DEFAULT_DIMS, 0.0) if text.to_s.strip.empty?
38
+ embed_batch([text]).first
39
+ end
30
40
 
31
- if Llmemory.configuration.embedding_cache_enabled
32
- key = cache_key(text)
33
- if @cache.key?(key)
41
+ def embed_batch(texts)
42
+ list = Array(texts).map(&:to_s)
43
+ return [] if list.empty?
44
+
45
+ results = list.map do |text|
46
+ if text.strip.empty?
47
+ Array.new(dimensions, 0.0)
48
+ elsif Llmemory.configuration.embedding_cache_enabled && @cache.key?(cache_key(text))
34
49
  @last_usage = Llmemory::LLM::Usage.zero
35
- return @cache[key].dup
50
+ @cache[cache_key(text)].dup
51
+ else
52
+ :miss
36
53
  end
37
54
  end
38
55
 
39
- result = fetch_embedding(text)
40
-
41
- if Llmemory.configuration.embedding_cache_enabled
42
- evict_if_needed
43
- @cache[cache_key(text)] = result.dup
44
- @cache_order << cache_key(text)
56
+ miss_indices = results.each_index.select { |i| results[i] == :miss }
57
+ if miss_indices.any?
58
+ fetched = fetch_embeddings(miss_indices.map { |i| list[i] })
59
+ miss_indices.each_with_index do |idx, j|
60
+ vec = fetched[j]
61
+ results[idx] = vec
62
+ if Llmemory.configuration.embedding_cache_enabled
63
+ evict_if_needed
64
+ @cache[cache_key(list[idx])] = vec.dup
65
+ @cache_order << cache_key(list[idx])
66
+ end
67
+ end
45
68
  end
46
-
47
- result
69
+ results
48
70
  end
49
71
 
50
72
  private
@@ -63,14 +85,16 @@ module Llmemory
63
85
  end
64
86
  end
65
87
 
66
- def fetch_embedding(text)
67
- result = nil
68
- payload = { provider: :openai, model: @model, text_chars: text.to_s.length }
88
+ def fetch_embeddings(texts)
89
+ payload = { provider: :openai, model: @model, text_chars: texts.sum(&:length) }
90
+ vectors = nil
69
91
  Llmemory::Instrumentation.instrument(:llm_embed, payload) do
92
+ body_input = { input: texts.map { |t| t.to_s.strip }, model: @model }
93
+ body_input[:dimensions] = @dimensions if @dimensions != DEFAULT_DIMS
70
94
  response = post_with_resilience(connection, "embeddings") do |req|
71
95
  req.headers["Authorization"] = "Bearer #{@api_key}"
72
96
  req.headers["Content-Type"] = "application/json"
73
- req.body = { input: text.to_s.strip, model: @model }.to_json
97
+ req.body = body_input.to_json
74
98
  end
75
99
  raise Llmemory::LLMError, "OpenAI Embeddings API error: #{response.body}" unless response.success?
76
100
  body = response.body.is_a?(Hash) ? response.body : JSON.parse(response.body.to_s)
@@ -80,9 +104,11 @@ module Llmemory
80
104
  output_tokens: @last_usage.output_tokens,
81
105
  total_tokens: @last_usage.total_tokens
82
106
  )
83
- result = body.dig("data", 0, "embedding")&.map(&:to_f) || Array.new(DEFAULT_DIMS, 0.0)
107
+ vectors = Array(body["data"]).sort_by { |row| row["index"] }.map do |row|
108
+ row["embedding"]&.map(&:to_f) || Array.new(dimensions, 0.0)
109
+ end
84
110
  end
85
- result
111
+ vectors
86
112
  end
87
113
 
88
114
  def parse_embed_usage(raw)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Llmemory
4
- VERSION = "0.2.11"
4
+ VERSION = "0.3.0"
5
5
  end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Llmemory
4
+ module ZeroMem
5
+ class AnswerCalibrator
6
+ def calibrate(answer, evidence_result:)
7
+ answer = answer.to_s
8
+ evidence = Array(evidence_result&.evidence)
9
+ checks = []
10
+ unsupported = []
11
+
12
+ if list_like?(answer)
13
+ checks << "list_answer"
14
+ return unchanged(answer, checks, unsupported)
15
+ end
16
+
17
+ scalar = extract_scalar_candidate(answer)
18
+ if scalar
19
+ matches = evidence.select { |ev| ev.content.to_s.include?(scalar) }
20
+ checks << "scalar_support"
21
+ if matches.size == 1
22
+ return {
23
+ answer: scalar,
24
+ changed: answer.strip != scalar,
25
+ checks: checks,
26
+ unsupported_fragments: unsupported
27
+ }
28
+ end
29
+
30
+ checks << "ambiguous_scalar"
31
+ return unchanged(answer, checks, unsupported)
32
+ end
33
+
34
+ checks << "free_text"
35
+ unchanged(answer, checks, unsupported)
36
+ end
37
+
38
+ private
39
+
40
+ def unchanged(answer, checks, unsupported)
41
+ {
42
+ answer: answer,
43
+ changed: false,
44
+ checks: checks,
45
+ unsupported_fragments: unsupported
46
+ }
47
+ end
48
+
49
+ def extract_scalar_candidate(answer)
50
+ stripped = answer.strip
51
+ return stripped if stripped.match?(/\A[\d.]+\z/)
52
+ return stripped if stripped.length <= 32 && stripped !~ /\s/
53
+
54
+ m = stripped.match(/\b([A-Z]{1,3})\b/)
55
+ m&.[](1)
56
+ end
57
+
58
+ def list_like?(answer)
59
+ answer.include?(",") || answer.match?(/\b(and|y)\b/i)
60
+ end
61
+ end
62
+ end
63
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Llmemory
6
+ module ZeroMem
7
+ class Backfill
8
+ WARNING = "Backfill only covers messages still present in the checkpoint; " \
9
+ "text removed by prior LLM compaction cannot be reconstructed."
10
+
11
+ def initialize(storage:, indexer: nil)
12
+ @storage = storage
13
+ @indexer = indexer || Indexer.new(storage)
14
+ end
15
+
16
+ def run!(user_id:, session_id:, messages:, id_prefix: "backfill")
17
+ created = []
18
+ Array(messages).each_with_index do |msg, idx|
19
+ role = msg[:role] || msg["role"]
20
+ content = msg[:content] || msg["content"]
21
+ next if content.to_s.empty?
22
+
23
+ sequence = @storage.next_sequence(user_id, session_id)
24
+ trace = Trace.build(
25
+ user_id: user_id,
26
+ session_id: session_id,
27
+ role: role,
28
+ content: content,
29
+ sequence: sequence,
30
+ idempotency_key: "#{id_prefix}-#{idx}-#{Digest::SHA256.hexdigest(content.to_s)[0, 12]}"
31
+ )
32
+ next if @storage.find_by_idempotency_key(user_id, trace.idempotency_key)
33
+
34
+ @storage.write_trace(trace)
35
+ @indexer.after_trace_write(trace: trace)
36
+ created << trace.id
37
+ end
38
+
39
+ { created_trace_ids: created, warning: WARNING }
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Llmemory
4
+ module ZeroMem
5
+ class BudgetAssembler
6
+ RESERVE_TOKENS = 64
7
+
8
+ def assemble(evidence, max_tokens:, local_reserve: RESERVE_TOKENS, atomic_groups: nil)
9
+ budget = max_tokens&.to_i
10
+ return evidence if budget.nil? || budget <= 0
11
+
12
+ reserve = [local_reserve, budget / 4].min
13
+ remaining = budget - reserve
14
+ selected = []
15
+ used_ids = {}
16
+
17
+ groups = build_atomic_groups(evidence, atomic_groups)
18
+ ungrouped = evidence.reject { |ev| groups.any? { |g| g.include?(ev.trace_id) } }
19
+
20
+ groups.each do |group|
21
+ members = group.map { |id| evidence.find { |ev| ev.trace_id == id } }.compact
22
+ cost = members.sum { |ev| token_cost(ev) }
23
+ next if cost > remaining
24
+
25
+ members.each do |ev|
26
+ selected << ev
27
+ used_ids[ev.trace_id] = true
28
+ end
29
+ remaining -= cost
30
+ end
31
+
32
+ ungrouped.each do |ev|
33
+ next if used_ids[ev.trace_id]
34
+
35
+ cost = token_cost(ev)
36
+ next if cost > remaining && !selected.empty?
37
+
38
+ if cost <= remaining
39
+ selected << ev
40
+ remaining -= cost
41
+ end
42
+ end
43
+
44
+ selected
45
+ end
46
+
47
+ def build_atomic_groups(evidence, explicit_groups)
48
+ ids = evidence.map(&:trace_id)
49
+ Array(explicit_groups).map do |group|
50
+ Array(group).map(&:to_s).select { |id| ids.include?(id) }
51
+ end.reject(&:empty?)
52
+ end
53
+
54
+ private
55
+
56
+ def token_cost(evidence)
57
+ line = "[#{evidence.trace_id}] #{evidence.role}: #{evidence.content}"
58
+ (Llmemory::Tokenizer.tokenize(line).size + 4)
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Llmemory
4
+ module ZeroMem
5
+ class CircuitBreaker
6
+ def initialize(failure_threshold: 3, cool_down_seconds: 30)
7
+ @failure_threshold = failure_threshold.to_i
8
+ @cool_down_seconds = cool_down_seconds.to_i
9
+ @failures = 0
10
+ @opened_at = nil
11
+ end
12
+
13
+ def allow_request?
14
+ return true if @opened_at.nil?
15
+ return false if (Time.now - @opened_at) < @cool_down_seconds
16
+
17
+ @opened_at = nil
18
+ @failures = 0
19
+ true
20
+ end
21
+
22
+ def record_success
23
+ @failures = 0
24
+ @opened_at = nil
25
+ end
26
+
27
+ def record_failure
28
+ @failures += 1
29
+ @opened_at = Time.now if @failures >= @failure_threshold
30
+ end
31
+
32
+ def open?
33
+ !allow_request?
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Llmemory
4
+ module ZeroMem
5
+ module EmbeddingProvider
6
+ def embed(texts)
7
+ raise NotImplementedError
8
+ end
9
+
10
+ def model
11
+ raise NotImplementedError
12
+ end
13
+
14
+ def dimensions
15
+ raise NotImplementedError
16
+ end
17
+
18
+ def local?
19
+ false
20
+ end
21
+
22
+ def last_usage
23
+ Llmemory::LLM::Usage.zero
24
+ end
25
+
26
+ def embed_one(text)
27
+ embed([text]).first
28
+ end
29
+ end
30
+
31
+ # Deterministic vectors for tests (no HTTP).
32
+ class FakeEmbeddingProvider
33
+ include EmbeddingProvider
34
+
35
+ attr_reader :calls
36
+
37
+ def initialize(dimensions: 8, model: "fake-embed")
38
+ @dimensions = dimensions
39
+ @model = model
40
+ @calls = 0
41
+ end
42
+
43
+ def embed(texts)
44
+ @calls += 1
45
+ Array(texts).map { |text| vector_for(text) }
46
+ end
47
+
48
+ def model
49
+ @model
50
+ end
51
+
52
+ def dimensions
53
+ @dimensions
54
+ end
55
+
56
+ def local?
57
+ true
58
+ end
59
+
60
+ private
61
+
62
+ def vector_for(text)
63
+ seed = Digest::SHA256.hexdigest(text.to_s)
64
+ @dimensions.times.map do |i|
65
+ byte = seed.bytes[i % seed.bytes.length] || 0
66
+ (byte / 255.0)
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
72
+
73
+ require "digest"
@@ -0,0 +1,247 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../retrieval/bm25_scorer"
4
+
5
+ module Llmemory
6
+ module ZeroMem
7
+ class Engine
8
+ def initialize(trace_store:, user_id:, config: nil, profiler: nil, retriever: nil,
9
+ graph_retriever: nil, calibrator: nil, assembler: nil, entity_index: nil,
10
+ embedding_provider: nil, router: nil, fusion: nil, closure: nil, **options)
11
+ if options.key?(:llm) || options.key?("llm")
12
+ raise ArgumentError, "ZeroMem::Engine does not accept an llm client"
13
+ end
14
+
15
+ @storage = trace_store
16
+ @user_id = user_id
17
+ @config = config || Llmemory.configuration
18
+ @profiler = profiler || QueryProfiler.new
19
+ @retriever = retriever || HierarchyRetriever.new(@storage, config: @config)
20
+ @entity_index = entity_index || EntityIndex.new(@storage)
21
+ @embedding_provider = embedding_provider
22
+ @graph_retriever = graph_retriever || GraphRetriever.new(
23
+ @storage,
24
+ entity_index: @entity_index,
25
+ embedding_provider: @embedding_provider,
26
+ config: @config
27
+ )
28
+ @calibrator = calibrator || EvidenceCalibrator.new(@storage)
29
+ @assembler = assembler || BudgetAssembler.new
30
+ @router = router || Router.new
31
+ @fusion = fusion || EvidenceFusion.new
32
+ @closure = closure || EvidenceClosure.new(@storage, config: @config)
33
+ @bm25 = Retrieval::Bm25Scorer.new
34
+ end
35
+
36
+ def retrieve_evidence(query, top_k: nil, max_tokens: nil, boundary: nil, explain: false,
37
+ current_trace_id: nil, skip_closure: false, skip_calibration: false,
38
+ fusion_weights: nil)
39
+ effective_k = effective_top_k(top_k, nil)
40
+ metrics = { llm_calls: 0, encoder_calls: 0, encoder_duration_ms: 0.0, stages: {} }
41
+
42
+ profile = nil
43
+ Llmemory::Instrumentation.instrument(:zero_mem_profile, user_id: @user_id, query_chars: query.to_s.length) do
44
+ profile = @profiler.profile(query, boundary: boundary)
45
+ end
46
+ effective_k = effective_top_k(top_k, profile)
47
+
48
+ retrieval = nil
49
+ Llmemory::Instrumentation.instrument(:zero_mem_hierarchy_retrieve, user_id: @user_id, workload: profile.workload_class) do
50
+ retrieval = @retriever.retrieve(user_id: @user_id, query: query, profile: profile, top_k: effective_k)
51
+ end
52
+
53
+ graph = nil
54
+ Llmemory::Instrumentation.instrument(:zero_mem_graph_retrieve, user_id: @user_id) do
55
+ graph = @graph_retriever.retrieve(user_id: @user_id, query: query, profile: profile, top_k: effective_k)
56
+ end
57
+
58
+ degraded = (retrieval[:degraded] + graph[:degraded]).uniq
59
+ degraded << :dense unless @embedding_provider
60
+ degraded.uniq!
61
+
62
+ hierarchy_scores = hierarchy_trace_scores(query, retrieval[:traces])
63
+ graph_scores = graph[:trace_scores] || {}
64
+ routing = @router.route(profile, hierarchy_scores: hierarchy_scores, graph_scores: graph_scores, config: @config)
65
+ weights = fusion_weights || routing[:weights]
66
+
67
+ fused = @fusion.fuse(graph_scores: graph_scores, hierarchy_scores: hierarchy_scores, weights: weights)
68
+ seed_rows = fused.first(effective_k)
69
+ seed_ids = seed_rows.map { |r| r[:trace_id] }
70
+
71
+ closure_result = if skip_closure
72
+ { seed_trace_ids: seed_ids, closure_trace_ids: [], bridges: [], atomic_groups: [] }
73
+ else
74
+ Llmemory::Instrumentation.instrument(:zero_mem_closure, user_id: @user_id, seeds: seed_ids.size) do
75
+ @closure.expand(
76
+ user_id: @user_id,
77
+ profile: profile,
78
+ seed_trace_ids: seed_ids,
79
+ fused_ranking: fused
80
+ )
81
+ end
82
+ end
83
+
84
+ ordered_ids = (seed_ids + closure_result[:closure_trace_ids]).uniq
85
+ traces = ordered_ids.map { |id| @storage.get_trace(@user_id, id) }.compact
86
+
87
+ calibration = if skip_calibration
88
+ { traces: traces, excluded: [], conflict_trace_ids: [], seed_ids: seed_ids,
89
+ closure_ids: closure_result[:closure_trace_ids] }
90
+ else
91
+ Llmemory::Instrumentation.instrument(:zero_mem_calibrate, user_id: @user_id, trace_count: traces.size) do
92
+ @calibrator.calibrate(
93
+ user_id: @user_id,
94
+ traces: traces,
95
+ profile: profile,
96
+ current_trace_id: current_trace_id,
97
+ fusion_rows: fused,
98
+ seed_ids: seed_ids,
99
+ closure_ids: closure_result[:closure_trace_ids]
100
+ )
101
+ end
102
+ end
103
+
104
+ fusion_by_id = fused.each_with_object({}) { |row, acc| acc[row[:trace_id].to_s] = row }
105
+ conflict_ids = calibration[:conflict_trace_ids].to_h { |id| [id.to_s, true] }
106
+
107
+ evidence = calibration[:traces].map do |trace|
108
+ row = fusion_by_id[trace.id]
109
+ score = row ? row[:final] : 0.5
110
+ Evidence.new(
111
+ trace_id: trace.id,
112
+ content: trace.content,
113
+ role: trace.role,
114
+ session_id: trace.session_id,
115
+ occurred_at: trace.occurred_at,
116
+ confidence: score.clamp(0.0, 1.0),
117
+ score: score.clamp(0.0, 1.0),
118
+ sources: row ? row[:sources] : [],
119
+ conflict: conflict_ids[trace.id] == true,
120
+ seed: seed_ids.include?(trace.id),
121
+ closure: closure_result[:closure_trace_ids].include?(trace.id),
122
+ content_sha256: trace.content_sha256
123
+ )
124
+ end
125
+
126
+ seed_evidence = evidence.select(&:seed)
127
+ closure_evidence = evidence.select(&:closure)
128
+
129
+ packed = @assembler.assemble(
130
+ evidence,
131
+ max_tokens: max_tokens,
132
+ atomic_groups: closure_result[:atomic_groups]
133
+ )
134
+
135
+ explain_payload = if explain
136
+ build_explain(
137
+ profile: profile,
138
+ routing: routing,
139
+ weights: weights,
140
+ effective_k: effective_k,
141
+ graph: graph,
142
+ retrieval: retrieval,
143
+ fused: fused,
144
+ calibration: calibration,
145
+ closure_result: closure_result,
146
+ degraded: degraded,
147
+ warnings: graph[:warnings]
148
+ )
149
+ end
150
+
151
+ result = EvidenceSet.new(
152
+ route: routing[:route],
153
+ profile: profile,
154
+ evidence: packed,
155
+ seed_evidence: seed_evidence.select { |ev| packed.any? { |p| p.trace_id == ev.trace_id } },
156
+ closure_evidence: closure_evidence.select { |ev| packed.any? { |p| p.trace_id == ev.trace_id } },
157
+ metrics: metrics.merge(
158
+ degraded: degraded,
159
+ seed_recall: seed_evidence.map(&:trace_id),
160
+ closure_recall: (seed_evidence + closure_evidence).map(&:trace_id).uniq
161
+ ),
162
+ explain: explain_payload,
163
+ degraded: degraded,
164
+ relational_trace_scores: graph_scores,
165
+ hierarchy_traces: retrieval[:traces],
166
+ relational_traces: graph[:traces],
167
+ warnings: graph[:warnings]
168
+ )
169
+
170
+ Llmemory::Instrumentation.instrument(
171
+ :zero_mem_retrieve,
172
+ user_id: @user_id,
173
+ evidence_count: packed.size,
174
+ degraded: degraded,
175
+ seed_count: seed_evidence.size,
176
+ closure_count: closure_evidence.size
177
+ )
178
+
179
+ result
180
+ end
181
+
182
+ def to_context(query, **opts)
183
+ retrieve_evidence(query, **opts).to_context
184
+ end
185
+
186
+ private
187
+
188
+ def effective_top_k(explicit, profile)
189
+ base = explicit || @config.zero_mem_top_k
190
+ need = profile ? [base, profile.expected_evidence_count].max : base
191
+ [need, @config.zero_mem_max_top_k].min
192
+ end
193
+
194
+ def hierarchy_trace_scores(query, traces)
195
+ docs = traces.map { |t| { id: t.id, text: t.content } }
196
+ return {} if docs.empty?
197
+
198
+ @bm25.score_documents(query, docs).to_h { |d| [d[:id], d[:normalized_bm25].to_f] }
199
+ end
200
+
201
+ def build_explain(profile:, routing:, weights:, effective_k:, graph:, retrieval:, fused:,
202
+ calibration:, closure_result:, degraded:, warnings:)
203
+ {
204
+ profile: profile.to_h,
205
+ route: routing[:route],
206
+ weights: weights,
207
+ thresholds: {
208
+ rho: routing[:rho],
209
+ gamma: @config.zero_mem_pagerank_damping,
210
+ top_k: effective_k
211
+ },
212
+ graph: {
213
+ seeds: graph[:trace_scores]&.keys&.first(5),
214
+ nodes_visited: graph[:traces]&.size,
215
+ truncated: warnings.include?(:pagerank_truncated),
216
+ boosts: []
217
+ },
218
+ hierarchy: {
219
+ episodes: retrieval[:stage_scores][:episodes],
220
+ windows: retrieval[:stage_scores][:windows],
221
+ turns: retrieval[:stage_scores][:turns]
222
+ },
223
+ fusion: {
224
+ per_trace: fused.first(effective_k).map do |row|
225
+ {
226
+ id: row[:trace_id],
227
+ graph: row[:graph],
228
+ hierarchy: row[:hierarchy],
229
+ final: row[:final],
230
+ sources: row[:sources]
231
+ }
232
+ end
233
+ },
234
+ included: calibration[:traces].map(&:id),
235
+ excluded: calibration[:excluded],
236
+ closure: {
237
+ seed_ids: closure_result[:seed_trace_ids],
238
+ closure_ids: closure_result[:closure_trace_ids],
239
+ bridges: closure_result[:bridges]
240
+ },
241
+ warnings: warnings,
242
+ degraded: degraded
243
+ }
244
+ end
245
+ end
246
+ end
247
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Llmemory
6
+ module ZeroMem
7
+ class Entity
8
+ attr_reader :id, :user_id, :normalized_key, :display_name, :entity_type, :ambiguous
9
+
10
+ def self.build(user_id:, normalized_key:, display_name: nil, entity_type: :entity, id: nil, ambiguous: false)
11
+ new(
12
+ id: id || "ent_#{Digest::SHA256.hexdigest([user_id, normalized_key].join(':'))[0, 16]}",
13
+ user_id: user_id.to_s,
14
+ normalized_key: normalized_key.to_s,
15
+ display_name: display_name || normalized_key.to_s,
16
+ entity_type: entity_type.to_sym,
17
+ ambiguous: ambiguous
18
+ )
19
+ end
20
+
21
+ def initialize(id:, user_id:, normalized_key:, display_name:, entity_type:, ambiguous:)
22
+ @id = id
23
+ @user_id = user_id
24
+ @normalized_key = normalized_key
25
+ @display_name = display_name
26
+ @entity_type = entity_type
27
+ @ambiguous = ambiguous
28
+ end
29
+ end
30
+ end
31
+ end