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
@@ -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
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../retrieval/bm25_scorer"
4
+
5
+ module Llmemory
6
+ module ZeroMem
7
+ class HierarchyRetriever
8
+ def initialize(storage, config: Llmemory.configuration)
9
+ @storage = storage
10
+ @config = config
11
+ @bm25 = Retrieval::Bm25Scorer.new
12
+ end
13
+
14
+ def retrieve(user_id:, query:, profile:, top_k:)
15
+ k = top_k || @config.zero_mem_top_k
16
+ episodes = filtered_episodes(user_id, profile)
17
+ ranked_episodes = rank_units(query, profile, episodes)
18
+ top_episodes = ranked_episodes.first([k, ranked_episodes.size].min)
19
+
20
+ windows = top_episodes.flat_map do |ep|
21
+ @storage.list_units(user_id, kind: :window, session_id: ep.session_id).select do |w|
22
+ (w.member_trace_ids - ep.member_trace_ids).empty? || (w.member_trace_ids & ep.member_trace_ids).any?
23
+ end
24
+ end
25
+ windows = @storage.list_units(user_id, kind: :window) if windows.empty?
26
+ ranked_windows = rank_units(query, profile, windows.uniq { |u| u.id })
27
+ top_windows = ranked_windows.first([k, ranked_windows.size].min)
28
+
29
+ turns = top_windows.flat_map do |win|
30
+ @storage.list_units(user_id, kind: :turn, session_id: win.session_id).select do |t|
31
+ t.start_sequence >= win.start_sequence && t.end_sequence <= win.end_sequence
32
+ end
33
+ end
34
+ turns = @storage.list_units(user_id, kind: :turn) if turns.empty?
35
+ ranked_turns = rank_units(query, profile, turns.uniq { |u| u.id })
36
+ top_turns = ranked_turns.first([k, ranked_turns.size].min)
37
+
38
+ trace_ids = top_turns.flat_map(&:member_trace_ids)
39
+ trace_ids = expand_local_neighbors(user_id, trace_ids, top_turns)
40
+ traces = trace_ids.map { |id| @storage.get_trace(user_id, id) }.compact
41
+
42
+ {
43
+ traces: traces,
44
+ degraded: [:dense],
45
+ stage_scores: {
46
+ episodes: ranked_episodes.first(3).map { |u| score_unit(query, profile, u) },
47
+ windows: ranked_windows.first(3).map { |u| score_unit(query, profile, u) },
48
+ turns: ranked_turns.first(3).map { |u| score_unit(query, profile, u) }
49
+ }
50
+ }
51
+ end
52
+
53
+ private
54
+
55
+ def filtered_episodes(user_id, profile)
56
+ units = @storage.list_units(user_id, kind: :episode)
57
+ boundary = profile.boundary
58
+ return units unless boundary
59
+
60
+ units.select do |u|
61
+ ok = true
62
+ ok &&= (u.session_id == boundary[:session_id]) if boundary[:session_id]
63
+ ok &&= (u.boundary_id == boundary[:boundary_id]) if boundary[:boundary_id]
64
+ ok
65
+ end
66
+ end
67
+
68
+ def rank_units(query, profile, units)
69
+ docs = units.map do |u|
70
+ { id: u.id, text: unit_text(u), unit: u }
71
+ end
72
+ scored = @bm25.score_documents(query, docs)
73
+ scored.sort_by { |d| -combined_score(query, profile, d) }
74
+ .map { |d| d[:unit] }
75
+ end
76
+
77
+ def combined_score(query, profile, doc)
78
+ unit = doc[:unit]
79
+ bm25 = doc[:normalized_bm25].to_f
80
+ phrase = phrase_boost(profile, doc[:text])
81
+ subject = subject_boost(profile, doc[:text])
82
+ temporal = temporal_boost(profile, unit)
83
+ boundary = profile.boundary ? 0.1 : 0.0
84
+ answer = 0.05
85
+ bm25 + phrase + subject + temporal + boundary + answer
86
+ end
87
+
88
+ def score_unit(query, profile, unit)
89
+ doc = @bm25.score_documents(query, [{ id: unit.id, text: unit_text(unit), unit: unit }]).first
90
+ combined_score(query, profile, doc)
91
+ end
92
+
93
+ def unit_text(unit)
94
+ unit.member_trace_ids.map do |tid|
95
+ @storage.get_trace(unit.user_id, tid)&.content
96
+ end.compact.join(" ")
97
+ end
98
+
99
+ def phrase_boost(profile, text)
100
+ down = text.downcase
101
+ profile.quoted_phrases.count { |p| down.include?(p.downcase) } * 0.2
102
+ end
103
+
104
+ def subject_boost(profile, text)
105
+ down = text.downcase
106
+ profile.subject_entities.count { |e| down.include?(e.downcase) } * 0.15
107
+ end
108
+
109
+ def temporal_boost(profile, unit)
110
+ return 0.0 unless profile.freshness_requirement
111
+
112
+ age = Time.now - unit.occurred_to
113
+ 1.0 / (1.0 + (age / 86_400.0))
114
+ end
115
+
116
+ def expand_local_neighbors(user_id, trace_ids, turn_units)
117
+ span = @config.zero_mem_local_span.to_i
118
+ span = 2 if span <= 0
119
+ sessions = turn_units.map(&:session_id).uniq
120
+ all_traces = sessions.flat_map { |sid| @storage.list_traces(user_id, session_id: sid) }
121
+ by_seq = all_traces.each_with_object({}) { |t, acc| acc[t.sequence] = t }
122
+ expanded = trace_ids.dup
123
+ turn_units.each do |tu|
124
+ seq = tu.start_sequence
125
+ ((seq - span)..(seq + span)).each do |s|
126
+ expanded << by_seq[s].id if by_seq[s]
127
+ end
128
+ end
129
+ expanded.uniq
130
+ end
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Llmemory
4
+ module ZeroMem
5
+ # Index watermark + hierarchical units + entity graph (ZM2/ZM3).
6
+ class Indexer
7
+ def initialize(storage, mode: nil, builder: nil, entity_index: nil, embedding_provider: nil)
8
+ @storage = storage
9
+ @mode = (mode || Llmemory.configuration.zero_mem_index_mode).to_sym
10
+ @builder = builder || HierarchyBuilder.new(storage)
11
+ @entity_index = entity_index || EntityIndex.new(storage)
12
+ @embedding_provider = embedding_provider
13
+ end
14
+
15
+ def after_trace_write(trace:, state_link: false)
16
+ wm = @storage.get_watermark(trace.user_id, trace.session_id)
17
+ next_version = wm[:index_version].to_i + 1
18
+ affected_units = @builder.index_trace(trace, index_version: next_version)
19
+ entity_stats = @entity_index.index_trace(trace)
20
+ encoder_stats = index_embedding(trace)
21
+
22
+ @storage.set_watermark(
23
+ trace.user_id,
24
+ trace.session_id,
25
+ last_sequence: trace.sequence,
26
+ index_version: next_version
27
+ )
28
+
29
+ {
30
+ index_mode: @mode,
31
+ index_lag: @mode == :deferred,
32
+ affected_units: affected_units,
33
+ affected_entities: entity_stats[:affected_entities],
34
+ cache_invalidations: entity_stats[:cache_invalidations],
35
+ maintenance_fanout: (state_link ? 2 : 1) + affected_units + entity_stats[:affected_entities],
36
+ encoder_calls: encoder_stats[:encoder_calls],
37
+ encoder_duration_ms: encoder_stats[:encoder_duration_ms]
38
+ }
39
+ end
40
+
41
+ private
42
+
43
+ def index_embedding(trace)
44
+ provider = @embedding_provider || configured_embedding_provider
45
+ return { encoder_calls: 0, encoder_duration_ms: 0.0 } unless provider
46
+
47
+ start = Process.clock_gettime(Process::CLOCK_MONOTONIC)
48
+ vector = provider.embed_one(trace.content)
49
+ elapsed = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start) * 1000).round(2)
50
+ @storage.store_turn_embedding(
51
+ trace.user_id,
52
+ trace.id,
53
+ vector,
54
+ model: provider.model,
55
+ dimensions: provider.dimensions
56
+ )
57
+ Llmemory::Instrumentation.instrument(
58
+ :encoder_embed,
59
+ user_id: trace.user_id,
60
+ trace_id: trace.id,
61
+ model: provider.model,
62
+ dimensions: provider.dimensions,
63
+ duration_ms: elapsed
64
+ )
65
+ { encoder_calls: 1, encoder_duration_ms: elapsed }
66
+ end
67
+
68
+ def configured_embedding_provider
69
+ Llmemory.configuration.zero_mem_embedding_provider
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Llmemory
4
+ module ZeroMem
5
+ module Mode
6
+ MODES = %i[classic zero_mem hybrid].freeze
7
+
8
+ module_function
9
+
10
+ def valid?(mode)
11
+ MODES.include?(mode.to_sym)
12
+ end
13
+
14
+ def normalize(mode)
15
+ sym = (mode || :classic).to_sym
16
+ raise Llmemory::ConfigurationError, "invalid memory_mode: #{mode.inspect}" unless valid?(sym)
17
+
18
+ sym
19
+ end
20
+
21
+ def zero_mem_enabled?(mode)
22
+ %i[zero_mem hybrid].include?(normalize(mode))
23
+ end
24
+
25
+ def zero_mem_strict?(mode)
26
+ normalize(mode) == :zero_mem
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Llmemory
4
+ module ZeroMem
5
+ class PageRank
6
+ def initialize(gamma: 0.6, max_iterations: 30, tolerance: 1e-6)
7
+ @gamma = gamma.to_f
8
+ @max_iterations = max_iterations
9
+ @tolerance = tolerance
10
+ end
11
+
12
+ # graph: { node_id => [neighbor_ids] }, reset: { node_id => weight }
13
+ def run(graph, reset)
14
+ nodes = (graph.keys + reset.keys).uniq
15
+ return {} if nodes.empty?
16
+
17
+ out_degree = graph.transform_values { |nbs| [nbs.size, 1].max }
18
+ rank = nodes.to_h { |n| [n, 1.0 / nodes.size] }
19
+ reset_total = reset.values.sum
20
+ reset = reset.transform_values { |w| reset_total.zero? ? 0.0 : w / reset_total }
21
+
22
+ @max_iterations.times do
23
+ next_rank = nodes.to_h { |n| [n, 0.0] }
24
+ graph.each do |node, neighbors|
25
+ share = rank[node] / out_degree[node]
26
+ neighbors.each do |nb|
27
+ next_rank[nb] ||= 0.0
28
+ next_rank[nb] += @gamma * share
29
+ end
30
+ end
31
+ nodes.each do |n|
32
+ next_rank[n] += (1.0 - @gamma) * reset.fetch(n, 0.0)
33
+ end
34
+ delta = nodes.sum { |n| (next_rank[n] - rank[n]).abs }
35
+ rank = next_rank
36
+ break if delta < @tolerance
37
+ end
38
+ rank
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Llmemory
4
+ module ZeroMem
5
+ class QueryProfile
6
+ ANSWER_TYPES = %i[person place datetime number duration boolean list free_text].freeze
7
+ WORKLOADS = %i[local_fact cross_session temporal current_state multi_hop procedural].freeze
8
+
9
+ attr_reader :subject_entities, :keywords, :quoted_phrases, :answer_type, :temporal_cues,
10
+ :aggregation_cues, :boundary, :workload_class, :freshness_requirement,
11
+ :expected_evidence_count, :language, :rule_ids
12
+
13
+ def initialize(subject_entities:, keywords:, quoted_phrases:, answer_type:, temporal_cues:,
14
+ aggregation_cues:, boundary:, workload_class:, freshness_requirement:,
15
+ expected_evidence_count:, language:, rule_ids:)
16
+ @subject_entities = subject_entities
17
+ @keywords = keywords
18
+ @quoted_phrases = quoted_phrases
19
+ @answer_type = answer_type
20
+ @temporal_cues = temporal_cues
21
+ @aggregation_cues = aggregation_cues
22
+ @boundary = boundary
23
+ @workload_class = workload_class
24
+ @freshness_requirement = freshness_requirement
25
+ @expected_evidence_count = expected_evidence_count
26
+ @language = language
27
+ @rule_ids = rule_ids
28
+ end
29
+
30
+ def to_h
31
+ {
32
+ subject_entities: subject_entities,
33
+ keywords: keywords,
34
+ quoted_phrases: quoted_phrases,
35
+ answer_type: answer_type,
36
+ temporal_cues: temporal_cues,
37
+ aggregation_cues: aggregation_cues,
38
+ boundary: boundary,
39
+ workload_class: workload_class,
40
+ freshness_requirement: freshness_requirement,
41
+ expected_evidence_count: expected_evidence_count,
42
+ language: language,
43
+ rule_ids: rule_ids
44
+ }
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,138 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Llmemory
4
+ module ZeroMem
5
+ class QueryProfiler
6
+ TEMPORAL_EN = /\b(before|after|first|last|when|timeline|january|february|march|april|may|june|july|august|september|october|november|december|booking|remind)\b/i
7
+ TEMPORAL_ES = /\b(antes|después|primero|último|ultimo|cuándo|cuando|hora|enero|febrero|marzo|abril|mayo|junio|julio|agosto|septiembre|octubre|noviembre|diciembre|reserva|recuerda)\b/i
8
+ CURRENT_EN = /\b(now|today|current|currently|what is)\b/i
9
+ CURRENT_ES = /\b(ahora|actual|hoy|cuál es|cual es)\b/i
10
+ MULTI_EN = /\b(between|compared to|relationship|and .+ and)\b/i
11
+ MULTI_ES = /\b(relación|relacion|entre|comparado)\b/i
12
+ PROCEDURAL = /\b(export|file|balance|informe|report|saldo)\b/i
13
+
14
+ def profile(query, boundary: nil, language: nil)
15
+ text = query.to_s.strip
16
+ lang = language || detect_language(text)
17
+ rule_ids = []
18
+ quoted = text.scan(/"([^"]+)"/).flatten
19
+ keywords = Llmemory::Tokenizer.tokenize(text)
20
+ entities = extract_entities(text, lang)
21
+
22
+ temporal = temporal_cues(text, lang)
23
+ rule_ids << "temporal_cues" unless temporal.empty?
24
+
25
+ workload = infer_workload(text, lang, entities, temporal, rule_ids)
26
+ answer_type = infer_answer_type(text, lang, rule_ids)
27
+ freshness = workload == :current_state || workload == :temporal
28
+
29
+ boundary_h = normalize_boundary(boundary)
30
+ rule_ids << "boundary_explicit" if boundary_h
31
+
32
+ QueryProfile.new(
33
+ subject_entities: entities,
34
+ keywords: keywords,
35
+ quoted_phrases: quoted,
36
+ answer_type: answer_type,
37
+ temporal_cues: temporal,
38
+ aggregation_cues: aggregation_cues(text),
39
+ boundary: boundary_h,
40
+ workload_class: workload,
41
+ freshness_requirement: freshness,
42
+ expected_evidence_count: expected_count(workload, aggregation_cues(text)),
43
+ language: lang,
44
+ rule_ids: rule_ids.uniq
45
+ )
46
+ end
47
+
48
+ private
49
+
50
+ def detect_language(text)
51
+ text.match?(/[¿¡áéíóúñÁÉÍÓÚÑ]/) || text.match?(TEMPORAL_ES) ? :es : :en
52
+ end
53
+
54
+ def extract_entities(text, _lang)
55
+ text.scan(/\b[A-Z][a-z]+(?:\s+[A-Z][a-z]+)*\b/).uniq.first(5)
56
+ end
57
+
58
+ def temporal_cues(text, lang)
59
+ cues = []
60
+ cues.concat(text.scan(lang == :es ? TEMPORAL_ES : TEMPORAL_EN).flatten)
61
+ cues.concat(text.scan(/\b\d{1,2}:\d{2}\b/))
62
+ cues.concat(text.scan(/\b\d{4}-\d{2}-\d{2}\b/))
63
+ cues.map(&:to_s).uniq
64
+ end
65
+
66
+ def aggregation_cues(text)
67
+ text.scan(/\b(all|every|list|cuántos|cuantos|how many)\b/i).flatten.uniq
68
+ end
69
+
70
+ def infer_workload(text, lang, entities, temporal, rule_ids)
71
+ if text.match?(PROCEDURAL)
72
+ rule_ids << "procedural_cue"
73
+ return :procedural
74
+ end
75
+ if entities.size >= 2 || text.match?(lang == :es ? MULTI_ES : MULTI_EN)
76
+ rule_ids << "multi_entity"
77
+ return :multi_hop
78
+ end
79
+ if text.match?(lang == :es ? CURRENT_ES : CURRENT_EN)
80
+ rule_ids << "current_state_cue"
81
+ return :current_state
82
+ end
83
+ if temporal.any?
84
+ rule_ids << "temporal_workload"
85
+ return :temporal
86
+ end
87
+ if text.match?(/\b(session|sesión|sesion|conversation|earlier)\b/i)
88
+ rule_ids << "cross_session_cue"
89
+ return :cross_session
90
+ end
91
+ :local_fact
92
+ end
93
+
94
+ def infer_answer_type(text, lang, rule_ids)
95
+ down = text.downcase
96
+ if down.match?(/\b(when|hora|time|fecha|date)\b/)
97
+ rule_ids << "answer_datetime"
98
+ return :datetime
99
+ end
100
+ if down.match?(/\b(how many|cuánt|cuant|number|número|numero)\b/)
101
+ rule_ids << "answer_number"
102
+ return :number
103
+ end
104
+ if down.match?(/\b(where|dónde|donde|place|address|dirección|direccion)\b/)
105
+ rule_ids << "answer_place"
106
+ return :place
107
+ end
108
+ if down.match?(/\b(yes|no|true|false|sí|si)\b/)
109
+ rule_ids << "answer_boolean"
110
+ return :boolean
111
+ end
112
+ if down.match?(/\b(list|which files|qué archivos|que archivos)\b/)
113
+ rule_ids << "answer_list"
114
+ return :list
115
+ end
116
+ :free_text
117
+ end
118
+
119
+ def expected_count(workload, aggregation_cues = [])
120
+ return [3, Llmemory.configuration.zero_mem_max_top_k].min if aggregation_cues.any?
121
+
122
+ case workload
123
+ when :multi_hop then 3
124
+ when :procedural then 2
125
+ when :temporal then 2
126
+ else 1
127
+ end
128
+ end
129
+
130
+ def normalize_boundary(boundary)
131
+ return nil if boundary.nil?
132
+
133
+ b = boundary.is_a?(Hash) ? boundary.transform_keys(&:to_sym) : { session_id: boundary }
134
+ b.compact
135
+ end
136
+ end
137
+ end
138
+ end