htm 0.0.10 → 0.0.14

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 (75) hide show
  1. checksums.yaml +4 -4
  2. data/.dictate.toml +46 -0
  3. data/.envrc +2 -0
  4. data/CHANGELOG.md +86 -3
  5. data/README.md +86 -7
  6. data/Rakefile +14 -2
  7. data/bin/htm_mcp.rb +621 -0
  8. data/config/database.yml +20 -13
  9. data/db/migrate/00010_add_soft_delete_to_associations.rb +29 -0
  10. data/db/migrate/00011_add_performance_indexes.rb +21 -0
  11. data/db/migrate/00012_add_tags_trigram_index.rb +18 -0
  12. data/db/migrate/00013_enable_lz4_compression.rb +43 -0
  13. data/db/schema.sql +49 -92
  14. data/docs/api/index.md +1 -1
  15. data/docs/api/yard/HTM.md +2 -4
  16. data/docs/architecture/index.md +1 -1
  17. data/docs/development/index.md +1 -1
  18. data/docs/getting-started/index.md +1 -1
  19. data/docs/guides/index.md +1 -1
  20. data/docs/images/telemetry-architecture.svg +153 -0
  21. data/docs/telemetry.md +391 -0
  22. data/examples/README.md +171 -1
  23. data/examples/cli_app/README.md +1 -1
  24. data/examples/cli_app/htm_cli.rb +1 -1
  25. data/examples/mcp_client.rb +529 -0
  26. data/examples/sinatra_app/app.rb +1 -1
  27. data/examples/telemetry/README.md +147 -0
  28. data/examples/telemetry/SETUP_README.md +169 -0
  29. data/examples/telemetry/demo.rb +498 -0
  30. data/examples/telemetry/grafana/dashboards/htm-metrics.json +457 -0
  31. data/lib/htm/configuration.rb +261 -70
  32. data/lib/htm/database.rb +46 -22
  33. data/lib/htm/embedding_service.rb +24 -14
  34. data/lib/htm/errors.rb +15 -1
  35. data/lib/htm/jobs/generate_embedding_job.rb +19 -0
  36. data/lib/htm/jobs/generate_propositions_job.rb +103 -0
  37. data/lib/htm/jobs/generate_tags_job.rb +24 -0
  38. data/lib/htm/loaders/markdown_chunker.rb +79 -0
  39. data/lib/htm/loaders/markdown_loader.rb +41 -15
  40. data/lib/htm/long_term_memory/fulltext_search.rb +138 -0
  41. data/lib/htm/long_term_memory/hybrid_search.rb +324 -0
  42. data/lib/htm/long_term_memory/node_operations.rb +209 -0
  43. data/lib/htm/long_term_memory/relevance_scorer.rb +355 -0
  44. data/lib/htm/long_term_memory/robot_operations.rb +34 -0
  45. data/lib/htm/long_term_memory/tag_operations.rb +428 -0
  46. data/lib/htm/long_term_memory/vector_search.rb +109 -0
  47. data/lib/htm/long_term_memory.rb +51 -1153
  48. data/lib/htm/models/node.rb +35 -2
  49. data/lib/htm/models/node_tag.rb +31 -0
  50. data/lib/htm/models/robot_node.rb +31 -0
  51. data/lib/htm/models/tag.rb +44 -0
  52. data/lib/htm/proposition_service.rb +169 -0
  53. data/lib/htm/query_cache.rb +214 -0
  54. data/lib/htm/sql_builder.rb +178 -0
  55. data/lib/htm/tag_service.rb +16 -6
  56. data/lib/htm/tasks.rb +8 -2
  57. data/lib/htm/telemetry.rb +224 -0
  58. data/lib/htm/version.rb +1 -1
  59. data/lib/htm.rb +64 -3
  60. data/lib/tasks/doc.rake +1 -1
  61. data/lib/tasks/htm.rake +259 -13
  62. data/mkdocs.yml +96 -96
  63. metadata +75 -18
  64. data/.aigcm_msg +0 -1
  65. data/.claude/settings.local.json +0 -92
  66. data/CLAUDE.md +0 -603
  67. data/examples/cli_app/temp.log +0 -93
  68. data/lib/htm/loaders/paragraph_chunker.rb +0 -112
  69. data/notes/ARCHITECTURE_REVIEW.md +0 -1167
  70. data/notes/IMPLEMENTATION_SUMMARY.md +0 -606
  71. data/notes/MULTI_FRAMEWORK_IMPLEMENTATION.md +0 -451
  72. data/notes/next_steps.md +0 -100
  73. data/notes/plan.md +0 -627
  74. data/notes/tag_ontology_enhancement_ideas.md +0 -222
  75. data/notes/timescaledb_removal_summary.md +0 -200
@@ -0,0 +1,355 @@
1
+ # frozen_string_literal: true
2
+
3
+ class HTM
4
+ class LongTermMemory
5
+ # Relevance scoring for search results
6
+ #
7
+ # Combines multiple signals to calculate dynamic relevance:
8
+ # - Vector similarity (semantic match) - config.relevance_semantic_weight (default: 0.5)
9
+ # - Tag overlap (categorical match) - config.relevance_tag_weight (default: 0.3)
10
+ # - Recency (freshness) - config.relevance_recency_weight (default: 0.1)
11
+ # - Access frequency (popularity/utility) - config.relevance_access_weight (default: 0.1)
12
+ #
13
+ # Recency decay uses configurable half-life: config.relevance_recency_half_life_hours (default: 168 = 1 week)
14
+ #
15
+ # Also provides tag similarity calculations using hierarchical Jaccard.
16
+ #
17
+ module RelevanceScorer
18
+ # Default score when signal is unavailable
19
+ DEFAULT_NEUTRAL_SCORE = 0.5
20
+
21
+ # Access frequency normalization
22
+ ACCESS_SCORE_NORMALIZER = 10.0
23
+
24
+ # Final score scaling
25
+ RELEVANCE_SCALE = 10.0
26
+ RELEVANCE_MIN = 0.0
27
+ RELEVANCE_MAX = 10.0
28
+
29
+ # Configurable scoring weights (via HTM.configuration)
30
+ def weight_semantic
31
+ HTM.configuration.relevance_semantic_weight
32
+ end
33
+
34
+ def weight_tag
35
+ HTM.configuration.relevance_tag_weight
36
+ end
37
+
38
+ def weight_recency
39
+ HTM.configuration.relevance_recency_weight
40
+ end
41
+
42
+ def weight_access
43
+ HTM.configuration.relevance_access_weight
44
+ end
45
+
46
+ def recency_half_life_hours
47
+ HTM.configuration.relevance_recency_half_life_hours
48
+ end
49
+
50
+ # Calculate dynamic relevance score for a node given query context
51
+ #
52
+ # @param node [Hash] Node data with similarity, tags, created_at, access_count
53
+ # @param query_tags [Array<String>] Tags associated with the query
54
+ # @param vector_similarity [Float, nil] Pre-computed vector similarity (0-1)
55
+ # @param node_tags [Array<String>, nil] Pre-loaded tags for this node (avoids N+1 query)
56
+ # @return [Float] Composite relevance score (RELEVANCE_MIN to RELEVANCE_MAX)
57
+ #
58
+ def calculate_relevance(node:, query_tags: [], vector_similarity: nil, node_tags: nil)
59
+ # 1. Vector similarity (semantic match)
60
+ semantic_score = if vector_similarity
61
+ vector_similarity
62
+ elsif node['similarity']
63
+ node['similarity'].to_f
64
+ else
65
+ DEFAULT_NEUTRAL_SCORE # Neutral if no embedding
66
+ end
67
+
68
+ # 2. Tag overlap (categorical relevance)
69
+ # Use pre-loaded tags if provided, otherwise fetch (for backward compatibility)
70
+ node_tags ||= get_node_tags(node['id'])
71
+ tag_score = if query_tags.any? && node_tags.any?
72
+ weighted_hierarchical_jaccard(query_tags, node_tags)
73
+ else
74
+ DEFAULT_NEUTRAL_SCORE # Neutral if no tags
75
+ end
76
+
77
+ # 3. Recency (temporal relevance) - exponential decay with half-life
78
+ age_hours = (Time.now - Time.parse(node['created_at'].to_s)) / 3600.0
79
+ recency_score = Math.exp(-age_hours / recency_half_life_hours)
80
+
81
+ # 4. Access frequency (behavioral signal) - log-normalized
82
+ access_count = node['access_count'] || 0
83
+ access_score = Math.log(1 + access_count) / ACCESS_SCORE_NORMALIZER
84
+
85
+ # Weighted composite with final scaling
86
+ relevance = (
87
+ (semantic_score * weight_semantic) +
88
+ (tag_score * weight_tag) +
89
+ (recency_score * weight_recency) +
90
+ (access_score * weight_access)
91
+ ) * RELEVANCE_SCALE
92
+
93
+ relevance.clamp(RELEVANCE_MIN, RELEVANCE_MAX)
94
+ end
95
+
96
+ # Search with dynamic relevance scoring
97
+ #
98
+ # Returns nodes with calculated relevance scores based on query context
99
+ #
100
+ # @param timeframe [nil, Range, Array<Range>] Time range(s) to search (nil = no filter)
101
+ # @param query [String, nil] Search query
102
+ # @param query_tags [Array<String>] Tags to match
103
+ # @param limit [Integer] Maximum results
104
+ # @param embedding_service [Object, nil] Service to generate embeddings
105
+ # @param metadata [Hash] Filter by metadata fields (default: {})
106
+ # @return [Array<Hash>] Nodes with relevance scores
107
+ #
108
+ def search_with_relevance(timeframe:, query: nil, query_tags: [], limit: 20, embedding_service: nil, metadata: {})
109
+ # Get candidates from appropriate search method
110
+ candidates = if query && embedding_service
111
+ # Vector search (returns hashes directly)
112
+ search_uncached(timeframe: timeframe, query: query, limit: limit * 2, embedding_service: embedding_service, metadata: metadata)
113
+ elsif query
114
+ # Full-text search (returns hashes directly)
115
+ search_fulltext_uncached(timeframe: timeframe, query: query, limit: limit * 2, metadata: metadata)
116
+ else
117
+ # Time-range only - use raw SQL to avoid ActiveRecord object instantiation
118
+ # This is more efficient than .map(&:attributes) which creates intermediate objects
119
+ fetch_candidates_by_timeframe(timeframe: timeframe, metadata: metadata, limit: limit * 2)
120
+ end
121
+
122
+ # Batch load all tags for candidates (fixes N+1 query)
123
+ node_ids = candidates.map { |n| n['id'] }
124
+ tags_by_node = batch_load_node_tags(node_ids)
125
+
126
+ # Calculate relevance for each candidate, building final hash in-place
127
+ scored_nodes = candidates.map do |node|
128
+ node_tags = tags_by_node[node['id']] || []
129
+
130
+ relevance = calculate_relevance(
131
+ node: node,
132
+ query_tags: query_tags,
133
+ vector_similarity: node['similarity']&.to_f,
134
+ node_tags: node_tags
135
+ )
136
+
137
+ # Modify in-place to avoid creating new Hash
138
+ node['relevance'] = relevance
139
+ node['tags'] = node_tags
140
+ node
141
+ end
142
+
143
+ # Sort by relevance and return top K
144
+ scored_nodes
145
+ .sort_by { |n| -n['relevance'] }
146
+ .take(limit)
147
+ end
148
+
149
+ # Fetch candidates by timeframe using raw SQL (avoids ActiveRecord overhead)
150
+ #
151
+ # @param timeframe [nil, Range, Array<Range>] Time range(s) to search
152
+ # @param metadata [Hash] Filter by metadata fields
153
+ # @param limit [Integer] Maximum results
154
+ # @return [Array<Hash>] Candidate nodes as hashes
155
+ #
156
+ def fetch_candidates_by_timeframe(timeframe:, metadata:, limit:)
157
+ timeframe_condition = HTM::SqlBuilder.timeframe_condition(timeframe)
158
+ metadata_condition = HTM::SqlBuilder.metadata_condition(metadata)
159
+
160
+ conditions = ['deleted_at IS NULL']
161
+ conditions << timeframe_condition if timeframe_condition
162
+ conditions << metadata_condition if metadata_condition
163
+
164
+ sql = <<~SQL
165
+ SELECT id, content, access_count, created_at, token_count
166
+ FROM nodes
167
+ WHERE #{conditions.join(' AND ')}
168
+ ORDER BY created_at DESC
169
+ LIMIT ?
170
+ SQL
171
+
172
+ result = ActiveRecord::Base.connection.select_all(
173
+ ActiveRecord::Base.sanitize_sql_array([sql, limit])
174
+ )
175
+ result.to_a
176
+ end
177
+
178
+ # Search nodes by tags
179
+ #
180
+ # @param tags [Array<String>] Tags to search for
181
+ # @param match_all [Boolean] If true, match ALL tags; if false, match ANY tag
182
+ # @param timeframe [Range, nil] Optional time range filter
183
+ # @param limit [Integer] Maximum results
184
+ # @return [Array<Hash>] Matching nodes with relevance scores
185
+ #
186
+ def search_by_tags(tags:, match_all: false, timeframe: nil, limit: 20)
187
+ return [] if tags.empty?
188
+
189
+ # Build base query with specific columns to avoid loading unnecessary data
190
+ query = HTM::Models::Node
191
+ .select('nodes.id, nodes.content, nodes.access_count, nodes.created_at, nodes.token_count')
192
+ .joins(:tags)
193
+ .where(tags: { name: tags })
194
+ .distinct
195
+
196
+ # Apply timeframe filter if provided
197
+ query = query.where(created_at: timeframe) if timeframe
198
+
199
+ if match_all
200
+ # Match ALL tags (intersection)
201
+ query = query
202
+ .group('nodes.id')
203
+ .having('COUNT(DISTINCT tags.name) = ?', tags.size)
204
+ end
205
+
206
+ # Convert to hashes efficiently using pluck-style approach
207
+ # This avoids instantiating full ActiveRecord objects
208
+ nodes = query.limit(limit).map do |node|
209
+ {
210
+ 'id' => node.id,
211
+ 'content' => node.content,
212
+ 'access_count' => node.access_count,
213
+ 'created_at' => node.created_at,
214
+ 'token_count' => node.token_count
215
+ }
216
+ end
217
+
218
+ # Batch load all tags for nodes (fixes N+1 query)
219
+ node_ids = nodes.map { |n| n['id'] }
220
+ tags_by_node = batch_load_node_tags(node_ids)
221
+
222
+ # Calculate relevance and enrich with tags (modify in-place)
223
+ nodes.map do |node|
224
+ node_tags = tags_by_node[node['id']] || []
225
+ relevance = calculate_relevance(
226
+ node: node,
227
+ query_tags: tags,
228
+ node_tags: node_tags
229
+ )
230
+
231
+ node['relevance'] = relevance
232
+ node['tags'] = node_tags
233
+ node
234
+ end.sort_by { |n| -n['relevance'] }
235
+ end
236
+
237
+ private
238
+
239
+ # Calculate Jaccard similarity between two sets
240
+ #
241
+ # @param set_a [Array] First set
242
+ # @param set_b [Array] Second set
243
+ # @return [Float] Jaccard similarity (0.0-1.0)
244
+ #
245
+ def jaccard_similarity(set_a, set_b)
246
+ return 0.0 if set_a.empty? && set_b.empty?
247
+ return 0.0 if set_a.empty? || set_b.empty?
248
+
249
+ intersection = (set_a & set_b).size
250
+ union = (set_a | set_b).size
251
+
252
+ intersection.to_f / union
253
+ end
254
+
255
+ # Calculate weighted hierarchical Jaccard similarity
256
+ #
257
+ # Compares hierarchical tags accounting for partial matches at different levels.
258
+ # Optimized to pre-compute tag hierarchies and use early termination.
259
+ #
260
+ # Performance: O(n*m) where n,m are tag counts, but with:
261
+ # - Pre-computed splits to avoid repeated String#split
262
+ # - Early termination when root categories don't match
263
+ # - Set-based exact match fast path
264
+ #
265
+ # @param set_a [Array<String>] First set of hierarchical tags
266
+ # @param set_b [Array<String>] Second set of hierarchical tags
267
+ # @return [Float] Weighted similarity (0.0-1.0)
268
+ #
269
+ def weighted_hierarchical_jaccard(set_a, set_b)
270
+ return 0.0 if set_a.empty? || set_b.empty?
271
+
272
+ # Fast path: check for exact matches first
273
+ exact_matches = (set_a & set_b).size
274
+ return 1.0 if exact_matches == set_a.size && exact_matches == set_b.size
275
+
276
+ # Pre-compute tag hierarchies to avoid repeated String#split
277
+ hierarchies_a = set_a.map { |tag| tag.split(':') }
278
+ hierarchies_b = set_b.map { |tag| tag.split(':') }
279
+
280
+ # Build root category index for early termination optimization
281
+ # Group tags by their root category for faster matching
282
+ roots_b = hierarchies_b.group_by(&:first)
283
+
284
+ total_weighted_similarity = 0.0
285
+ total_weights = 0.0
286
+
287
+ hierarchies_a.each do |parts_a|
288
+ root_a = parts_a.first
289
+
290
+ # Only compare with tags that share the same root category
291
+ matching_hierarchies = roots_b[root_a] || []
292
+
293
+ # Also include all hierarchies if no root match (for cross-category comparison)
294
+ candidates = matching_hierarchies.empty? ? hierarchies_b : matching_hierarchies
295
+
296
+ candidates.each do |parts_b|
297
+ similarity, weight = calculate_hierarchical_similarity_cached(parts_a, parts_b)
298
+ total_weighted_similarity += similarity * weight
299
+ total_weights += weight
300
+ end
301
+
302
+ # Add zero-similarity weight for non-matching root categories
303
+ (hierarchies_b.size - candidates.size).times do
304
+ # Non-matching roots contribute weight but zero similarity
305
+ total_weights += 0.5 # Average weight for non-matches
306
+ end
307
+ end
308
+
309
+ total_weights > 0 ? total_weighted_similarity / total_weights : 0.0
310
+ end
311
+
312
+ # Calculate similarity between two pre-split hierarchical tags
313
+ #
314
+ # Optimized version that takes pre-split arrays to avoid redundant splits.
315
+ #
316
+ # @param parts_a [Array<String>] First tag hierarchy (pre-split)
317
+ # @param parts_b [Array<String>] Second tag hierarchy (pre-split)
318
+ # @return [Array<Float, Float>] [similarity, weight] both in range 0.0-1.0
319
+ #
320
+ def calculate_hierarchical_similarity_cached(parts_a, parts_b)
321
+ # Calculate overlap at each level using zip for efficiency
322
+ max_depth = [parts_a.length, parts_b.length].max
323
+ min_depth = [parts_a.length, parts_b.length].min
324
+
325
+ # Count common levels from root
326
+ common_levels = 0
327
+ min_depth.times do |i|
328
+ break unless parts_a[i] == parts_b[i]
329
+ common_levels += 1
330
+ end
331
+
332
+ # Weight based on hierarchy depth (deeper = less weight)
333
+ depth_weight = 1.0 / max_depth
334
+
335
+ # Normalized similarity
336
+ similarity = common_levels.to_f / max_depth
337
+
338
+ [similarity, depth_weight]
339
+ end
340
+
341
+ # Calculate similarity between two hierarchical tags (string version)
342
+ #
343
+ # Compares tags level by level, returning both similarity and a weight
344
+ # based on hierarchy depth (higher levels = more weight).
345
+ #
346
+ # @param tag_a [String] First tag (e.g., "database:postgresql:extensions")
347
+ # @param tag_b [String] Second tag (e.g., "database:postgresql:queries")
348
+ # @return [Array<Float, Float>] [similarity, weight] both in range 0.0-1.0
349
+ #
350
+ def calculate_hierarchical_similarity(tag_a, tag_b)
351
+ calculate_hierarchical_similarity_cached(tag_a.split(':'), tag_b.split(':'))
352
+ end
353
+ end
354
+ end
355
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ class HTM
4
+ class LongTermMemory
5
+ # Robot registration and activity tracking
6
+ #
7
+ # Handles robot lifecycle management including:
8
+ # - Registration (find or create)
9
+ # - Activity timestamp updates
10
+ #
11
+ module RobotOperations
12
+ # Register a robot
13
+ #
14
+ # @param robot_name [String] Robot name
15
+ # @return [Integer] Robot ID
16
+ #
17
+ def register_robot(robot_name)
18
+ robot = HTM::Models::Robot.find_or_create_by(name: robot_name)
19
+ robot.update(last_active: Time.current)
20
+ robot.id
21
+ end
22
+
23
+ # Update robot activity timestamp
24
+ #
25
+ # @param robot_id [Integer] Robot identifier
26
+ # @return [void]
27
+ #
28
+ def update_robot_activity(robot_id)
29
+ robot = HTM::Models::Robot.find_by(id: robot_id)
30
+ robot&.update(last_active: Time.current)
31
+ end
32
+ end
33
+ end
34
+ end