llmemory 0.2.10 → 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 (79) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +48 -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 +67 -1
  9. data/lib/llmemory/consolidation/filter.rb +74 -0
  10. data/lib/llmemory/consolidation/policy.rb +17 -0
  11. data/lib/llmemory/consolidation/rule_based_policy.rb +63 -0
  12. data/lib/llmemory/consolidation/unit.rb +54 -0
  13. data/lib/llmemory/consolidation.rb +11 -0
  14. data/lib/llmemory/instrumentation.rb +10 -1
  15. data/lib/llmemory/long_term/file_based/memory.rb +18 -7
  16. data/lib/llmemory/long_term/graph_based/memory.rb +31 -8
  17. data/lib/llmemory/maintenance/cognitive_pass.rb +29 -1
  18. data/lib/llmemory/maintenance/policy_prune.rb +85 -0
  19. data/lib/llmemory/maintenance/zero_mem_ttl.rb +26 -0
  20. data/lib/llmemory/maintenance.rb +2 -0
  21. data/lib/llmemory/mcp/server.rb +2 -0
  22. data/lib/llmemory/mcp/store_helpers.rb +16 -0
  23. data/lib/llmemory/mcp/tools/memory_add_message.rb +17 -4
  24. data/lib/llmemory/mcp/tools/memory_consolidate.rb +9 -4
  25. data/lib/llmemory/mcp/tools/memory_maintain.rb +6 -1
  26. data/lib/llmemory/mcp/tools/memory_retrieve.rb +3 -1
  27. data/lib/llmemory/mcp/tools/memory_retrieve_evidence.rb +70 -0
  28. data/lib/llmemory/mcp/tools/memory_stats.rb +22 -0
  29. data/lib/llmemory/memory.rb +257 -9
  30. data/lib/llmemory/retrieval/bm25_scorer.rb +5 -0
  31. data/lib/llmemory/retrieval/context_assembler.rb +17 -1
  32. data/lib/llmemory/tokenizer.rb +18 -8
  33. data/lib/llmemory/vector_store/memory_store.rb +23 -1
  34. data/lib/llmemory/vector_store/openai_embeddings.rb +47 -21
  35. data/lib/llmemory/version.rb +1 -1
  36. data/lib/llmemory/zero_mem/answer_calibrator.rb +63 -0
  37. data/lib/llmemory/zero_mem/backfill.rb +43 -0
  38. data/lib/llmemory/zero_mem/budget_assembler.rb +62 -0
  39. data/lib/llmemory/zero_mem/circuit_breaker.rb +37 -0
  40. data/lib/llmemory/zero_mem/embedding_provider.rb +73 -0
  41. data/lib/llmemory/zero_mem/engine.rb +247 -0
  42. data/lib/llmemory/zero_mem/entity.rb +31 -0
  43. data/lib/llmemory/zero_mem/entity_extractor.rb +23 -0
  44. data/lib/llmemory/zero_mem/entity_index.rb +65 -0
  45. data/lib/llmemory/zero_mem/entity_mention.rb +35 -0
  46. data/lib/llmemory/zero_mem/errors.rb +7 -0
  47. data/lib/llmemory/zero_mem/evidence.rb +43 -0
  48. data/lib/llmemory/zero_mem/evidence_calibrator.rb +158 -0
  49. data/lib/llmemory/zero_mem/evidence_closure.rb +98 -0
  50. data/lib/llmemory/zero_mem/evidence_fusion.rb +57 -0
  51. data/lib/llmemory/zero_mem/evidence_set.rb +54 -0
  52. data/lib/llmemory/zero_mem/extractors/heuristic.rb +44 -0
  53. data/lib/llmemory/zero_mem/extractors/http.rb +58 -0
  54. data/lib/llmemory/zero_mem/graph_retriever.rb +107 -0
  55. data/lib/llmemory/zero_mem/hierarchy_builder.rb +123 -0
  56. data/lib/llmemory/zero_mem/hierarchy_retriever.rb +133 -0
  57. data/lib/llmemory/zero_mem/indexer.rb +73 -0
  58. data/lib/llmemory/zero_mem/mode.rb +30 -0
  59. data/lib/llmemory/zero_mem/pagerank.rb +42 -0
  60. data/lib/llmemory/zero_mem/query_profile.rb +48 -0
  61. data/lib/llmemory/zero_mem/query_profiler.rb +138 -0
  62. data/lib/llmemory/zero_mem/repair.rb +31 -0
  63. data/lib/llmemory/zero_mem/router.rb +69 -0
  64. data/lib/llmemory/zero_mem/sidecar_client.rb +82 -0
  65. data/lib/llmemory/zero_mem/storage.rb +124 -0
  66. data/lib/llmemory/zero_mem/storages/active_record.rb +311 -0
  67. data/lib/llmemory/zero_mem/storages/active_record_models.rb +21 -0
  68. data/lib/llmemory/zero_mem/storages/file.rb +67 -0
  69. data/lib/llmemory/zero_mem/storages/memory.rb +294 -0
  70. data/lib/llmemory/zero_mem/storages/postgres.rb +85 -0
  71. data/lib/llmemory/zero_mem/storages/snapshot_codec.rb +180 -0
  72. data/lib/llmemory/zero_mem/storages.rb +43 -0
  73. data/lib/llmemory/zero_mem/trace.rb +100 -0
  74. data/lib/llmemory/zero_mem/trace_state_link.rb +64 -0
  75. data/lib/llmemory/zero_mem/trace_unit.rb +83 -0
  76. data/lib/llmemory/zero_mem.rb +42 -0
  77. data/lib/llmemory.rb +8 -0
  78. data/lib/tasks/llmemory.rake +84 -2
  79. metadata +54 -2
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Llmemory
6
+ module ZeroMem
7
+ class EvidenceCalibrator
8
+ CONFLICT_PATTERNS = [
9
+ /\btalla\s+es\s+([A-Z])\b/i,
10
+ /\bsize\s+is\s+([A-Z])\b/i
11
+ ].freeze
12
+
13
+ def initialize(storage)
14
+ @storage = storage
15
+ end
16
+
17
+ def calibrate(user_id:, traces:, profile:, current_trace_id: nil, as_of: Time.now,
18
+ fusion_rows: nil, seed_ids: nil, closure_ids: nil)
19
+ excluded = []
20
+ kept = []
21
+
22
+ Array(traces).each do |t|
23
+ reason = exclusion_reason(user_id, t, profile, current_trace_id, as_of)
24
+ if reason
25
+ excluded << { id: t.id, reason: reason }
26
+ next
27
+ end
28
+
29
+ kept << t
30
+ end
31
+
32
+ deduped, dup_excluded = dedupe_by_content(kept, fusion_rows)
33
+ excluded.concat(dup_excluded)
34
+
35
+ conflict_ids = detect_conflicts(deduped)
36
+ ordered = sort_traces(deduped, profile, as_of)
37
+
38
+ {
39
+ traces: ordered,
40
+ excluded: excluded,
41
+ conflict_trace_ids: conflict_ids,
42
+ seed_ids: Array(seed_ids).map(&:to_s),
43
+ closure_ids: Array(closure_ids).map(&:to_s)
44
+ }
45
+ end
46
+
47
+ private
48
+
49
+ def exclusion_reason(user_id, trace, profile, current_trace_id, as_of)
50
+ return :wrong_tenant unless trace.user_id == user_id.to_s
51
+ return :archived unless trace.active?
52
+ return :current_query if current_trace_id && trace.id == current_trace_id.to_s
53
+ return :outside_boundary unless boundary_match?(trace, profile.boundary)
54
+ return :hash_mismatch unless sha_ok?(trace)
55
+ return :stale_state unless current_state_ok?(user_id, trace, profile, as_of)
56
+
57
+ nil
58
+ end
59
+
60
+ def boundary_match?(trace, boundary)
61
+ return true unless boundary
62
+
63
+ ok = true
64
+ ok &&= (trace.session_id == boundary[:session_id].to_s) if boundary[:session_id]
65
+ ok &&= (trace.boundary_id == boundary[:boundary_id].to_s) if boundary[:boundary_id]
66
+ ok
67
+ end
68
+
69
+ def sha_ok?(trace)
70
+ Digest::SHA256.hexdigest(trace.content) == trace.content_sha256
71
+ end
72
+
73
+ def current_state_ok?(user_id, trace, profile, as_of)
74
+ return true unless profile.workload_class == :current_state
75
+
76
+ links = @storage.state_links_for(user_id, state_attribute_key(trace))
77
+ return true if links.empty?
78
+
79
+ current_id = @storage.current_state_trace_id(user_id, state_attribute_key(trace), as_of: as_of)
80
+ return true if current_id.nil?
81
+
82
+ trace.id == current_id.to_s || links.any? { |l| l.trace_id == trace.id }
83
+ end
84
+
85
+ def state_attribute_key(trace)
86
+ down = trace.content.downcase
87
+ return "talla" if down.include?("talla")
88
+ return "size" if down.include?("size")
89
+
90
+ "state:#{trace.session_id}"
91
+ end
92
+
93
+ def dedupe_by_content(traces, fusion_rows)
94
+ by_hash = traces.group_by(&:content_sha256)
95
+ kept = []
96
+ excluded = []
97
+ by_hash.each do |hash, group|
98
+ if group.size == 1
99
+ kept << group.first
100
+ next
101
+ end
102
+
103
+ winner = pick_winner(group, fusion_rows)
104
+ kept << winner
105
+ group.each do |t|
106
+ next if t.id == winner.id
107
+
108
+ excluded << { id: t.id, reason: :duplicate_content, content_sha256: hash }
109
+ end
110
+ end
111
+ [kept, excluded]
112
+ end
113
+
114
+ def pick_winner(group, fusion_rows)
115
+ scores = fusion_rows&.index_by { |r| r[:trace_id].to_s } || {}
116
+ group.max_by do |t|
117
+ row = scores[t.id]
118
+ [row ? row[:final].to_f : 0.0, t.occurred_at.to_f]
119
+ end
120
+ end
121
+
122
+ def detect_conflicts(traces)
123
+ buckets = Hash.new { |h, k| h[k] = [] }
124
+ traces.each do |t|
125
+ CONFLICT_PATTERNS.each do |pat|
126
+ m = t.content.match(pat)
127
+ next unless m
128
+
129
+ buckets[pat.source] << { id: t.id, value: m[1].upcase }
130
+ end
131
+ end
132
+
133
+ conflict_ids = []
134
+ buckets.each_value do |entries|
135
+ values = entries.map { |e| e[:value] }.uniq
136
+ next if values.size <= 1
137
+
138
+ conflict_ids.concat(entries.map { |e| e[:id] })
139
+ end
140
+ conflict_ids.uniq
141
+ end
142
+
143
+ def sort_traces(traces, profile, as_of)
144
+ if profile.freshness_requirement || profile.workload_class == :current_state
145
+ traces.sort_by { |t| [-t.occurred_at.to_f, subject_rank(t, profile)] }
146
+ else
147
+ traces.sort_by { |t| [subject_rank(t, profile), -t.occurred_at.to_f] }
148
+ end
149
+ end
150
+
151
+ def subject_rank(trace, profile)
152
+ down = trace.content.downcase
153
+ hits = profile.subject_entities.count { |e| down.include?(e.downcase) }
154
+ -hits
155
+ end
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Llmemory
4
+ module ZeroMem
5
+ class EvidenceClosure
6
+ DEFAULT_BRIDGE_BUDGET = 4
7
+ DEFAULT_NEIGHBOR_BUDGET = 4
8
+
9
+ def initialize(storage, config: Llmemory.configuration)
10
+ @storage = storage
11
+ @config = config
12
+ end
13
+
14
+ # Expands fused seeds with relational bridges and local neighbors inside the boundary.
15
+ def expand(user_id:, profile:, seed_trace_ids:, fused_ranking:)
16
+ seed_trace_ids = Array(seed_trace_ids).map(&:to_s).uniq
17
+ return empty(seed_trace_ids) if seed_trace_ids.empty?
18
+
19
+ bridge_budget = [seed_trace_ids.size, DEFAULT_BRIDGE_BUDGET].min
20
+ neighbor_budget = DEFAULT_NEIGHBOR_BUDGET
21
+ per_seed = [neighbor_budget / [seed_trace_ids.size, 1].max, 1].max
22
+
23
+ closure_ids = []
24
+ bridges = []
25
+ groups = []
26
+
27
+ seed_trace_ids.each do |seed_id|
28
+ group = [seed_id]
29
+ added = 0
30
+ prev, nxt = @storage.adjacent_traces(user_id, seed_id)
31
+ [prev, nxt].compact.each do |tid|
32
+ break if added >= per_seed
33
+ next if seed_trace_ids.include?(tid)
34
+ next unless trace_ok?(user_id, tid, profile)
35
+
36
+ closure_ids << tid
37
+ bridges << [seed_id, tid]
38
+ group << tid
39
+ added += 1
40
+ end
41
+ groups << group if group.size > 1
42
+ end
43
+
44
+ span = @config.zero_mem_local_span.to_i
45
+ span = 2 if span <= 0
46
+ seed_trace_ids.each do |seed_id|
47
+ trace = @storage.get_trace(user_id, seed_id)
48
+ next unless trace
49
+
50
+ session_traces = @storage.list_traces(user_id, session_id: trace.session_id)
51
+ by_seq = session_traces.each_with_object({}) { |t, acc| acc[t.sequence] = t.id }
52
+ ((trace.sequence - span)..(trace.sequence + span)).each do |seq|
53
+ tid = by_seq[seq]
54
+ next if tid.nil? || seed_trace_ids.include?(tid) || closure_ids.include?(tid)
55
+ next unless trace_ok?(user_id, tid, profile)
56
+
57
+ closure_ids << tid
58
+ end
59
+ end
60
+
61
+ closure_ids = closure_ids.uniq.first(bridge_budget + neighbor_budget)
62
+ {
63
+ seed_trace_ids: seed_trace_ids,
64
+ closure_trace_ids: closure_ids,
65
+ bridges: bridges.first(bridge_budget),
66
+ atomic_groups: groups.map { |g| g.uniq.sort }
67
+ }
68
+ end
69
+
70
+ private
71
+
72
+ def empty(seed_trace_ids)
73
+ {
74
+ seed_trace_ids: seed_trace_ids,
75
+ closure_trace_ids: [],
76
+ bridges: [],
77
+ atomic_groups: []
78
+ }
79
+ end
80
+
81
+ def trace_ok?(user_id, trace_id, profile)
82
+ trace = @storage.get_trace(user_id, trace_id)
83
+ return false unless trace&.active?
84
+
85
+ boundary_match?(trace, profile.boundary)
86
+ end
87
+
88
+ def boundary_match?(trace, boundary)
89
+ return true unless boundary
90
+
91
+ ok = true
92
+ ok &&= (trace.session_id == boundary[:session_id].to_s) if boundary[:session_id]
93
+ ok &&= (trace.boundary_id == boundary[:boundary_id].to_s) if boundary[:boundary_id]
94
+ ok
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Llmemory
4
+ module ZeroMem
5
+ class EvidenceFusion
6
+ # When all raw scores are equal, min-max yields this normalized value (see benchmarks/zero_mem/profile.yml).
7
+ EQUAL_SCORE_NORMALIZED = 0.5
8
+
9
+ def fuse(graph_scores:, hierarchy_scores:, weights:)
10
+ g_norm = min_max(graph_scores)
11
+ h_norm = min_max(hierarchy_scores)
12
+ ids = (g_norm.keys + h_norm.keys).uniq
13
+
14
+ fused = ids.map do |trace_id|
15
+ graph = g_norm.fetch(trace_id, 0.0)
16
+ hierarchy = h_norm.fetch(trace_id, 0.0)
17
+ graph_w = weights.fetch(:graph, 0.5).to_f
18
+ hierarchy_w = weights.fetch(:hierarchy, 0.5).to_f
19
+ total = graph_w + hierarchy_w
20
+ graph_w /= total
21
+ hierarchy_w /= total
22
+ final = (graph_w * graph) + (hierarchy_w * hierarchy)
23
+ sources = []
24
+ sources << :graph if graph_scores.key?(trace_id)
25
+ sources << :hierarchy if hierarchy_scores.key?(trace_id)
26
+
27
+ {
28
+ trace_id: trace_id,
29
+ graph: graph,
30
+ hierarchy: hierarchy,
31
+ graph_raw: graph_scores[trace_id],
32
+ hierarchy_raw: hierarchy_scores[trace_id],
33
+ final: final,
34
+ sources: sources
35
+ }
36
+ end
37
+
38
+ fused.sort_by { |row| [-row[:final], row[:trace_id]] }
39
+ end
40
+
41
+ def min_max(scores)
42
+ return {} if scores.nil? || scores.empty?
43
+
44
+ values = scores.values.map(&:to_f)
45
+ min = values.min
46
+ max = values.max
47
+ if max == min
48
+ return scores.keys.to_h { |id| [id, EQUAL_SCORE_NORMALIZED] }
49
+ end
50
+
51
+ scores.transform_values do |v|
52
+ ((v.to_f - min) / (max - min)).clamp(0.0, 1.0)
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Llmemory
4
+ module ZeroMem
5
+ class EvidenceSet
6
+ attr_reader :route, :profile, :evidence, :metrics, :explain, :degraded,
7
+ :relational_trace_scores, :hierarchy_traces, :relational_traces, :warnings,
8
+ :seed_evidence, :closure_evidence
9
+
10
+ def initialize(route:, profile:, evidence:, metrics:, explain: nil, degraded: [],
11
+ relational_trace_scores: {}, hierarchy_traces: [], relational_traces: [],
12
+ warnings: [], seed_evidence: [], closure_evidence: [])
13
+ @route = route
14
+ @profile = profile
15
+ @evidence = evidence
16
+ @metrics = metrics
17
+ @explain = explain
18
+ @degraded = Array(degraded)
19
+ @relational_trace_scores = relational_trace_scores
20
+ @hierarchy_traces = hierarchy_traces
21
+ @relational_traces = relational_traces
22
+ @warnings = Array(warnings)
23
+ @seed_evidence = Array(seed_evidence)
24
+ @closure_evidence = Array(closure_evidence)
25
+ end
26
+
27
+ def recall_at_k(gold_trace_ids, k: nil)
28
+ k ||= evidence.size
29
+ gold = Array(gold_trace_ids).map(&:to_s)
30
+ ranked = evidence.map(&:trace_id).first(k)
31
+ hit = (gold & ranked).size
32
+ {
33
+ hit: hit,
34
+ gold: gold.size,
35
+ recall: gold.empty? ? 0.0 : hit.to_f / gold.size,
36
+ ranked: ranked
37
+ }
38
+ end
39
+
40
+ def to_context
41
+ return "" if evidence.empty?
42
+
43
+ lines = ["=== ZERO-MEM EVIDENCE ===", ""]
44
+ evidence.each do |ev|
45
+ ts = ev.occurred_at.is_a?(Time) ? ev.occurred_at.utc.iso8601 : ev.occurred_at.to_s
46
+ lines << "[#{ev.trace_id}] #{ts} #{ev.session_id} #{ev.role}: #{ev.content}"
47
+ end
48
+ lines << ""
49
+ lines << "=== END ZERO-MEM EVIDENCE ==="
50
+ lines.join("\n")
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../entity_extractor"
4
+
5
+ module Llmemory
6
+ module ZeroMem
7
+ module Extractors
8
+ class Heuristic < EntityExtractor
9
+ CAP_PHRASE = /\b[\p{Lu}][\p{Ll}]+(?:\s+[\p{Lu}][\p{Ll}]+)*\b/u
10
+ ALL_CAPS = /\b[\p{Lu}]{2,}\b/u
11
+
12
+ def extract(text)
13
+ normalized_text = Llmemory::Tokenizer.normalize(text)
14
+ spans = []
15
+ text.to_s.scan(CAP_PHRASE) { |m| spans << [Regexp.last_match.begin(0), m] }
16
+ text.to_s.scan(ALL_CAPS) { |m| spans << [Regexp.last_match.begin(0), m] }
17
+
18
+ spans.map do |start, raw|
19
+ norm = normalize_key(raw)
20
+ next if norm.length < 2
21
+
22
+ {
23
+ text: raw,
24
+ normalized: norm,
25
+ type: :entity,
26
+ start: start,
27
+ end: start + raw.length
28
+ }
29
+ end.compact.uniq { |h| h[:normalized] }
30
+ end
31
+
32
+ def name
33
+ "heuristic"
34
+ end
35
+
36
+ private
37
+
38
+ def normalize_key(raw)
39
+ Llmemory::Tokenizer.normalize(raw).gsub(/\s+/, " ").strip
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "faraday"
5
+ require_relative "../entity_extractor"
6
+
7
+ module Llmemory
8
+ module ZeroMem
9
+ module Extractors
10
+ class Http < EntityExtractor
11
+ def initialize(base_url:, timeout: 5, client: nil)
12
+ @base_url = base_url.to_s.sub(%r{/\z}, "")
13
+ @timeout = timeout
14
+ @client = client
15
+ end
16
+
17
+ def extract(text)
18
+ body = sidecar.post_json("/ner", { text: text.to_s })
19
+ Array(body["entities"]).map do |ent|
20
+ {
21
+ text: ent["text"],
22
+ normalized: ent["normalized"] || ent["text"].to_s.downcase,
23
+ type: (ent["type"] || "entity").to_sym,
24
+ start: ent["start"] || 0,
25
+ end: ent["end"] || 0
26
+ }
27
+ end
28
+ end
29
+
30
+ def name
31
+ "http"
32
+ end
33
+
34
+ def version
35
+ health["version"] || "unknown"
36
+ rescue StandardError
37
+ "unknown"
38
+ end
39
+
40
+ def health
41
+ sidecar.get_json("/health")
42
+ rescue StoreError
43
+ { "status" => "down" }
44
+ end
45
+
46
+ def degraded?
47
+ sidecar.degraded?
48
+ end
49
+
50
+ private
51
+
52
+ def sidecar
53
+ @client ||= SidecarClient.new(base_url: @base_url, timeout: @timeout)
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Llmemory
4
+ module ZeroMem
5
+ class GraphRetriever
6
+ MAX_NODES = 200
7
+
8
+ def initialize(storage, entity_index: nil, embedding_provider: nil, config: Llmemory.configuration)
9
+ @storage = storage
10
+ @entity_index = entity_index || EntityIndex.new(storage)
11
+ @embedding_provider = embedding_provider
12
+ @config = config
13
+ @pagerank = PageRank.new(gamma: config.zero_mem_pagerank_damping)
14
+ end
15
+
16
+ def retrieve(user_id:, query:, profile:, top_k:)
17
+ warnings = []
18
+ degraded = []
19
+ query_entities = @entity_index.extract_entities(query)
20
+ degraded << :ner if query_entities.empty?
21
+
22
+ seeds = match_entities(user_id, query, query_entities, degraded: degraded)
23
+ return empty_result(degraded, warnings) if seeds.empty?
24
+
25
+ trace_ids = seeds.flat_map { |key| @storage.trace_ids_for_entity_key(user_id, key) }.uniq
26
+ trace_ids = trace_ids.first(MAX_NODES)
27
+ warnings << :pagerank_truncated if trace_ids.size >= MAX_NODES
28
+
29
+ graph, reset = build_graph(user_id, trace_ids, seeds)
30
+ ranks = @pagerank.run(graph, reset)
31
+ trace_scores = ranks.select { |node, _| node.start_with?("tr:") }
32
+ .transform_keys { |k| k.sub("tr:", "") }
33
+ trace_ids.each { |tid| trace_scores[tid] ||= 0.5 }
34
+ trace_scores = trace_scores.sort_by { |_, score| -score }
35
+
36
+ traces = trace_scores.first(top_k).map do |trace_id, score|
37
+ t = @storage.get_trace(user_id, trace_id)
38
+ next unless t
39
+
40
+ { trace: t, score: score }
41
+ end.compact
42
+
43
+ {
44
+ traces: traces.map { |h| h[:trace] },
45
+ trace_scores: trace_scores.to_h,
46
+ degraded: degraded.uniq,
47
+ warnings: warnings
48
+ }
49
+ end
50
+
51
+ private
52
+
53
+ def empty_result(degraded, warnings)
54
+ { traces: [], trace_scores: {}, degraded: degraded.uniq, warnings: warnings }
55
+ end
56
+
57
+ def match_entities(user_id, query, query_entities, degraded:)
58
+ keys = query_entities.map { |e| e[:normalized] }.compact
59
+ tokens = Llmemory::Tokenizer.tokenize(query)
60
+ keys = expand_entity_keys(user_id, keys) if keys.any?
61
+ if keys.empty?
62
+ keys = @storage.all_entity_keys(user_id).select do |key|
63
+ tokens.any? { |t| key.include?(t) || t.include?(key) }
64
+ end
65
+ end
66
+ degraded << :dense if @embedding_provider.nil?
67
+ keys.select { |k| @storage.entity_exists?(user_id, k) }
68
+ end
69
+
70
+ def expand_entity_keys(user_id, keys)
71
+ stored = @storage.all_entity_keys(user_id)
72
+ keys.flat_map do |k|
73
+ next [] if k.to_s.length < 2
74
+
75
+ stored.select { |s| s == k || s.include?(k) || k.include?(s) }
76
+ end.uniq
77
+ end
78
+
79
+ def build_graph(user_id, trace_ids, seed_keys)
80
+ graph = {}
81
+ reset = {}
82
+
83
+ trace_ids.each do |tid|
84
+ node = "tr:#{tid}"
85
+ neighbors = ["tr:#{tid}"]
86
+ @storage.entity_keys_for_trace(user_id, tid).each do |key|
87
+ ent_node = "ent:#{key}"
88
+ graph[ent_node] ||= []
89
+ graph[ent_node] << node
90
+ graph[node] ||= []
91
+ graph[node] << ent_node
92
+ neighbors << ent_node
93
+ reset[ent_node] = 1.0 if seed_keys.include?(key)
94
+ end
95
+ prev, nxt = @storage.adjacent_traces(user_id, tid)
96
+ [prev, nxt].compact.each do |other|
97
+ graph[node] ||= []
98
+ graph[node] << "tr:#{other}"
99
+ end
100
+ graph[node] = (graph[node] || neighbors).uniq
101
+ end
102
+
103
+ [graph, reset]
104
+ end
105
+ end
106
+ end
107
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Llmemory
4
+ module ZeroMem
5
+ class HierarchyBuilder
6
+ def initialize(storage, config: Llmemory.configuration)
7
+ @storage = storage
8
+ @config = config
9
+ end
10
+
11
+ def index_trace(trace, index_version:)
12
+ affected = 0
13
+ affected += write_turn_unit(trace, index_version)
14
+ affected += update_tail_windows(trace, index_version)
15
+ affected += update_episode(trace, index_version)
16
+ affected
17
+ end
18
+
19
+ private
20
+
21
+ def window_size
22
+ @config.zero_mem_window_size.to_i.positive? ? @config.zero_mem_window_size.to_i : 8
23
+ end
24
+
25
+ def window_overlap
26
+ ov = @config.zero_mem_window_overlap.to_i
27
+ ov = 2 if ov.negative?
28
+ [ov, window_size - 1].min
29
+ end
30
+
31
+ def episode_gap_seconds
32
+ gap = @config.zero_mem_episode_gap_seconds.to_i
33
+ gap.positive? ? gap : 86_400
34
+ end
35
+
36
+ def write_turn_unit(trace, index_version)
37
+ unit = TraceUnit.build(
38
+ id: "turn_#{trace.id}",
39
+ user_id: trace.user_id,
40
+ kind: :turn,
41
+ session_id: trace.session_id,
42
+ boundary_id: trace.boundary_id,
43
+ start_sequence: trace.sequence,
44
+ end_sequence: trace.sequence,
45
+ occurred_from: trace.occurred_at,
46
+ occurred_to: trace.occurred_at,
47
+ member_trace_ids: [trace.id],
48
+ index_version: index_version
49
+ )
50
+ @storage.write_unit(unit)
51
+ 1
52
+ end
53
+
54
+ def update_tail_windows(trace, index_version)
55
+ size = window_size
56
+ seq = trace.sequence
57
+ start_seq = [1, seq - size + 1].max
58
+ traces = @storage.list_traces(trace.user_id, session_id: trace.session_id).select do |t|
59
+ t.sequence >= start_seq && t.sequence <= seq
60
+ end
61
+ return 0 if traces.empty?
62
+
63
+ unit = TraceUnit.build(
64
+ id: "window_#{trace.session_id}_#{traces.first.sequence}_#{traces.last.sequence}",
65
+ user_id: trace.user_id,
66
+ kind: :window,
67
+ session_id: trace.session_id,
68
+ boundary_id: trace.boundary_id,
69
+ start_sequence: traces.first.sequence,
70
+ end_sequence: traces.last.sequence,
71
+ occurred_from: traces.first.occurred_at,
72
+ occurred_to: traces.last.occurred_at,
73
+ member_trace_ids: traces.map(&:id),
74
+ index_version: index_version
75
+ )
76
+ @storage.write_unit(unit)
77
+ 1
78
+ end
79
+
80
+ def update_episode(trace, index_version)
81
+ key = trace.boundary_id || trace.session_id
82
+ open_episode = @storage.open_episode(trace.user_id, key)
83
+ gap = episode_gap_seconds
84
+
85
+ if open_episode
86
+ last_at = open_episode.occurred_to
87
+ if (trace.occurred_at - last_at).to_f > gap
88
+ @storage.close_episode(open_episode.id)
89
+ open_episode = nil
90
+ end
91
+ end
92
+
93
+ if open_episode
94
+ ids = (open_episode.member_trace_ids + [trace.id]).uniq
95
+ traces = ids.map { |id| @storage.get_trace(trace.user_id, id) }.compact
96
+ updated = open_episode.with(
97
+ end_sequence: trace.sequence,
98
+ occurred_to: trace.occurred_at,
99
+ member_trace_ids: ids,
100
+ index_version: index_version
101
+ )
102
+ @storage.write_unit(updated)
103
+ else
104
+ unit = TraceUnit.build(
105
+ user_id: trace.user_id,
106
+ kind: :episode,
107
+ session_id: trace.session_id,
108
+ boundary_id: trace.boundary_id,
109
+ start_sequence: trace.sequence,
110
+ end_sequence: trace.sequence,
111
+ occurred_from: trace.occurred_at,
112
+ occurred_to: trace.occurred_at,
113
+ member_trace_ids: [trace.id],
114
+ index_version: index_version
115
+ )
116
+ @storage.write_unit(unit)
117
+ @storage.set_open_episode(trace.user_id, key, unit.id)
118
+ end
119
+ 1
120
+ end
121
+ end
122
+ end
123
+ end