htm 0.0.11 → 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 (74) hide show
  1. checksums.yaml +4 -4
  2. data/.dictate.toml +46 -0
  3. data/.envrc +2 -0
  4. data/CHANGELOG.md +52 -2
  5. data/README.md +79 -0
  6. data/Rakefile +14 -2
  7. data/bin/htm_mcp.rb +94 -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 +46 -1
  23. data/examples/cli_app/README.md +1 -1
  24. data/examples/cli_app/htm_cli.rb +1 -1
  25. data/examples/sinatra_app/app.rb +1 -1
  26. data/examples/telemetry/README.md +147 -0
  27. data/examples/telemetry/SETUP_README.md +169 -0
  28. data/examples/telemetry/demo.rb +498 -0
  29. data/examples/telemetry/grafana/dashboards/htm-metrics.json +457 -0
  30. data/lib/htm/configuration.rb +261 -70
  31. data/lib/htm/database.rb +46 -22
  32. data/lib/htm/embedding_service.rb +24 -14
  33. data/lib/htm/errors.rb +15 -1
  34. data/lib/htm/jobs/generate_embedding_job.rb +19 -0
  35. data/lib/htm/jobs/generate_propositions_job.rb +103 -0
  36. data/lib/htm/jobs/generate_tags_job.rb +24 -0
  37. data/lib/htm/loaders/markdown_chunker.rb +79 -0
  38. data/lib/htm/loaders/markdown_loader.rb +41 -15
  39. data/lib/htm/long_term_memory/fulltext_search.rb +138 -0
  40. data/lib/htm/long_term_memory/hybrid_search.rb +324 -0
  41. data/lib/htm/long_term_memory/node_operations.rb +209 -0
  42. data/lib/htm/long_term_memory/relevance_scorer.rb +355 -0
  43. data/lib/htm/long_term_memory/robot_operations.rb +34 -0
  44. data/lib/htm/long_term_memory/tag_operations.rb +428 -0
  45. data/lib/htm/long_term_memory/vector_search.rb +109 -0
  46. data/lib/htm/long_term_memory.rb +51 -1153
  47. data/lib/htm/models/node.rb +35 -2
  48. data/lib/htm/models/node_tag.rb +31 -0
  49. data/lib/htm/models/robot_node.rb +31 -0
  50. data/lib/htm/models/tag.rb +44 -0
  51. data/lib/htm/proposition_service.rb +169 -0
  52. data/lib/htm/query_cache.rb +214 -0
  53. data/lib/htm/sql_builder.rb +178 -0
  54. data/lib/htm/tag_service.rb +16 -6
  55. data/lib/htm/tasks.rb +8 -2
  56. data/lib/htm/telemetry.rb +224 -0
  57. data/lib/htm/version.rb +1 -1
  58. data/lib/htm.rb +64 -3
  59. data/lib/tasks/doc.rake +1 -1
  60. data/lib/tasks/htm.rake +259 -13
  61. data/mkdocs.yml +96 -96
  62. metadata +42 -16
  63. data/.aigcm_msg +0 -1
  64. data/.claude/settings.local.json +0 -95
  65. data/CLAUDE.md +0 -603
  66. data/examples/cli_app/temp.log +0 -93
  67. data/lib/htm/loaders/paragraph_chunker.rb +0 -112
  68. data/notes/ARCHITECTURE_REVIEW.md +0 -1167
  69. data/notes/IMPLEMENTATION_SUMMARY.md +0 -606
  70. data/notes/MULTI_FRAMEWORK_IMPLEMENTATION.md +0 -451
  71. data/notes/next_steps.md +0 -100
  72. data/notes/plan.md +0 -627
  73. data/notes/tag_ontology_enhancement_ideas.md +0 -222
  74. data/notes/timescaledb_removal_summary.md +0 -200
@@ -0,0 +1,324 @@
1
+ # frozen_string_literal: true
2
+
3
+ class HTM
4
+ class LongTermMemory
5
+ # Hybrid search combining full-text and vector similarity
6
+ #
7
+ # Performs combined search using:
8
+ # 1. Full-text search for content matching
9
+ # 2. Tag matching for categorical relevance
10
+ # 3. Vector similarity for semantic ranking
11
+ #
12
+ # Nodes without embeddings are included with a default similarity score,
13
+ # allowing newly created nodes to appear immediately before background
14
+ # jobs complete their embedding generation.
15
+ #
16
+ # Results are cached for performance.
17
+ #
18
+ # Security: All queries use parameterized placeholders to prevent SQL injection.
19
+ #
20
+ module HybridSearch
21
+ # Maximum results to prevent DoS via unbounded queries
22
+ MAX_HYBRID_LIMIT = 1000
23
+ MAX_PREFILTER_LIMIT = 5000
24
+
25
+ # Hybrid search (full-text + vector)
26
+ #
27
+ # @param timeframe [Range] Time range to search
28
+ # @param query [String] Search query
29
+ # @param limit [Integer] Maximum results (capped at MAX_HYBRID_LIMIT)
30
+ # @param embedding_service [Object] Service to generate embeddings
31
+ # @param prefilter_limit [Integer] Candidates to consider (default: 100, capped at MAX_PREFILTER_LIMIT)
32
+ # @param metadata [Hash] Filter by metadata fields (default: {})
33
+ # @return [Array<Hash>] Matching nodes
34
+ #
35
+ def search_hybrid(timeframe:, query:, limit:, embedding_service:, prefilter_limit: 100, metadata: {})
36
+ # Enforce limits to prevent DoS
37
+ safe_limit = [[limit.to_i, 1].max, MAX_HYBRID_LIMIT].min
38
+ safe_prefilter = [[prefilter_limit.to_i, 1].max, MAX_PREFILTER_LIMIT].min
39
+
40
+ start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
41
+ result = @cache.fetch(:hybrid, timeframe, query, safe_limit, safe_prefilter, metadata) do
42
+ search_hybrid_uncached(
43
+ timeframe: timeframe,
44
+ query: query,
45
+ limit: safe_limit,
46
+ embedding_service: embedding_service,
47
+ prefilter_limit: safe_prefilter,
48
+ metadata: metadata
49
+ )
50
+ end
51
+ elapsed_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round
52
+ HTM::Telemetry.search_latency.record(elapsed_ms, attributes: { 'strategy' => 'hybrid' })
53
+ result
54
+ end
55
+
56
+ private
57
+
58
+ # Threshold for skipping tag extraction (as ratio of limit)
59
+ # If fulltext returns >= this ratio of requested results, skip expensive tag extraction
60
+ TAG_EXTRACTION_THRESHOLD = 0.5
61
+
62
+ # Uncached hybrid search
63
+ #
64
+ # Generates query embedding client-side, then combines:
65
+ # 1. Full-text search for content matching
66
+ # 2. Tag matching for categorical relevance (lazy - skipped if fulltext sufficient)
67
+ # 3. Vector similarity for semantic ranking
68
+ #
69
+ # @param timeframe [nil, Range, Array<Range>] Time range(s) to search (nil = no filter)
70
+ # @param query [String] Search query
71
+ # @param limit [Integer] Maximum results
72
+ # @param embedding_service [Object] Service to generate query embedding
73
+ # @param prefilter_limit [Integer] Candidates to consider
74
+ # @param metadata [Hash] Filter by metadata fields (default: {})
75
+ # @return [Array<Hash>] Matching nodes with similarity and tag_boost scores
76
+ #
77
+ def search_hybrid_uncached(timeframe:, query:, limit:, embedding_service:, prefilter_limit:, metadata: {})
78
+ # Generate query embedding client-side
79
+ query_embedding = embedding_service.embed(query)
80
+
81
+ # Validate embedding before use
82
+ unless query_embedding.is_a?(Array) && query_embedding.any?
83
+ HTM.logger.error("Invalid embedding returned from embedding service")
84
+ return []
85
+ end
86
+
87
+ # Pad embedding to 2000 dimensions if needed
88
+ padded_embedding = HTM::SqlBuilder.pad_embedding(query_embedding)
89
+
90
+ # Sanitize embedding for safe SQL use (validates all values are numeric)
91
+ embedding_str = HTM::SqlBuilder.sanitize_embedding(padded_embedding)
92
+
93
+ # Build filter conditions (with table alias for CTEs)
94
+ timeframe_condition = HTM::SqlBuilder.timeframe_condition(timeframe, table_alias: 'n')
95
+ metadata_condition = HTM::SqlBuilder.metadata_condition(metadata, table_alias: 'n')
96
+
97
+ additional_conditions = []
98
+ additional_conditions << timeframe_condition if timeframe_condition
99
+ additional_conditions << metadata_condition if metadata_condition
100
+ additional_sql = additional_conditions.any? ? "AND #{additional_conditions.join(' AND ')}" : ""
101
+
102
+ # Same for non-aliased queries
103
+ timeframe_condition_bare = HTM::SqlBuilder.timeframe_condition(timeframe)
104
+ metadata_condition_bare = HTM::SqlBuilder.metadata_condition(metadata)
105
+
106
+ additional_conditions_bare = []
107
+ additional_conditions_bare << timeframe_condition_bare if timeframe_condition_bare
108
+ additional_conditions_bare << metadata_condition_bare if metadata_condition_bare
109
+ additional_sql_bare = additional_conditions_bare.any? ? "AND #{additional_conditions_bare.join(' AND ')}" : ""
110
+
111
+ # OPTIMIZATION: Lazy tag extraction
112
+ # Only extract tags if fulltext results are insufficient.
113
+ # This skips the expensive LLM call (~500-3000ms) when fulltext alone
114
+ # provides enough results.
115
+ fulltext_count = count_fulltext_matches(
116
+ query: query,
117
+ additional_sql_bare: additional_sql_bare,
118
+ limit: prefilter_limit
119
+ )
120
+
121
+ # Only call expensive tag extraction if fulltext results are below threshold
122
+ matching_tags = if fulltext_count < (limit * TAG_EXTRACTION_THRESHOLD)
123
+ find_query_matching_tags(query)
124
+ else
125
+ []
126
+ end
127
+
128
+ # Build the hybrid query
129
+ # NOTE: Hybrid search includes nodes without embeddings using a default
130
+ # similarity score of 0.5. This allows newly created nodes to appear in
131
+ # search results immediately (via fulltext matching) before their embeddings
132
+ # are generated by background jobs.
133
+
134
+ result = if matching_tags.any?
135
+ search_hybrid_with_tags(
136
+ query: query,
137
+ embedding_str: embedding_str,
138
+ matching_tags: matching_tags,
139
+ additional_sql: additional_sql,
140
+ prefilter_limit: prefilter_limit,
141
+ limit: limit
142
+ )
143
+ else
144
+ search_hybrid_without_tags(
145
+ query: query,
146
+ embedding_str: embedding_str,
147
+ additional_sql_bare: additional_sql_bare,
148
+ prefilter_limit: prefilter_limit,
149
+ limit: limit
150
+ )
151
+ end
152
+
153
+ # Track access for retrieved nodes
154
+ node_ids = result.map { |r| r['id'] }
155
+ track_access(node_ids)
156
+
157
+ result.to_a
158
+ end
159
+
160
+ # Count fulltext matches quickly (for lazy tag extraction decision)
161
+ #
162
+ # @param query [String] Search query
163
+ # @param additional_sql_bare [String] Additional SQL conditions
164
+ # @param limit [Integer] Maximum to count up to
165
+ # @return [Integer] Number of fulltext matches (capped at limit)
166
+ #
167
+ def count_fulltext_matches(query:, additional_sql_bare:, limit:)
168
+ sql = <<~SQL
169
+ SELECT COUNT(*) FROM (
170
+ SELECT 1 FROM nodes
171
+ WHERE deleted_at IS NULL
172
+ AND to_tsvector('english', content) @@ plainto_tsquery('english', ?)
173
+ #{additional_sql_bare}
174
+ LIMIT ?
175
+ ) AS limited_count
176
+ SQL
177
+
178
+ result = ActiveRecord::Base.connection.select_value(
179
+ ActiveRecord::Base.sanitize_sql_array([sql, query, limit])
180
+ )
181
+ result.to_i
182
+ end
183
+
184
+ # Hybrid search with tag matching
185
+ #
186
+ # Uses parameterized queries and LEFT JOIN for efficient tag boosting.
187
+ #
188
+ # @param query [String] Search query
189
+ # @param embedding_str [String] Sanitized embedding string
190
+ # @param matching_tags [Array<String>] Tags matching the query
191
+ # @param additional_sql [String] Additional SQL conditions
192
+ # @param prefilter_limit [Integer] Candidates to consider
193
+ # @param limit [Integer] Maximum results
194
+ # @return [ActiveRecord::Result] Query results
195
+ #
196
+ def search_hybrid_with_tags(query:, embedding_str:, matching_tags:, additional_sql:, prefilter_limit:, limit:)
197
+ # Build tag placeholders for parameterized query
198
+ tag_placeholders = matching_tags.map { '?' }.join(', ')
199
+ tag_count = matching_tags.length.to_f
200
+
201
+ # Use parameterized query with proper placeholder binding
202
+ # LEFT JOIN replaces correlated subquery for O(n) instead of O(n²)
203
+ sql = <<~SQL
204
+ WITH fulltext_candidates AS (
205
+ -- Nodes matching full-text search (with or without embeddings)
206
+ SELECT n.id, n.content, n.access_count, n.created_at, n.token_count, n.embedding
207
+ FROM nodes n
208
+ WHERE n.deleted_at IS NULL
209
+ AND to_tsvector('english', n.content) @@ plainto_tsquery('english', ?)
210
+ #{additional_sql}
211
+ LIMIT ?
212
+ ),
213
+ tag_candidates AS (
214
+ -- Nodes matching relevant tags (with or without embeddings)
215
+ SELECT n.id, n.content, n.access_count, n.created_at, n.token_count, n.embedding
216
+ FROM nodes n
217
+ JOIN node_tags nt ON nt.node_id = n.id
218
+ JOIN tags t ON t.id = nt.tag_id
219
+ WHERE n.deleted_at IS NULL
220
+ AND t.name IN (#{tag_placeholders})
221
+ #{additional_sql}
222
+ LIMIT ?
223
+ ),
224
+ all_candidates AS (
225
+ SELECT * FROM fulltext_candidates
226
+ UNION
227
+ SELECT * FROM tag_candidates
228
+ ),
229
+ tag_counts AS (
230
+ -- Pre-compute tag counts using JOIN instead of correlated subquery
231
+ SELECT nt.node_id, COUNT(DISTINCT t.name)::float AS matched_tags
232
+ FROM node_tags nt
233
+ JOIN tags t ON t.id = nt.tag_id
234
+ WHERE t.name IN (#{tag_placeholders})
235
+ GROUP BY nt.node_id
236
+ ),
237
+ scored AS (
238
+ SELECT
239
+ ac.id, ac.content, ac.access_count, ac.created_at, ac.token_count,
240
+ CASE
241
+ WHEN ac.embedding IS NOT NULL THEN 1 - (ac.embedding <=> ?::vector)
242
+ ELSE 0.5
243
+ END as similarity,
244
+ COALESCE(tc.matched_tags / ?, 0) as tag_boost
245
+ FROM all_candidates ac
246
+ LEFT JOIN tag_counts tc ON tc.node_id = ac.id
247
+ )
248
+ SELECT id, content, access_count, created_at, token_count,
249
+ similarity, tag_boost,
250
+ (similarity * 0.7 + tag_boost * 0.3) as combined_score
251
+ FROM scored
252
+ ORDER BY combined_score DESC
253
+ LIMIT ?
254
+ SQL
255
+
256
+ # Build parameter array: query, prefilter, tags (first IN), prefilter, tags (second IN), embedding, tag_count, limit
257
+ params = [
258
+ query,
259
+ prefilter_limit,
260
+ *matching_tags,
261
+ prefilter_limit,
262
+ *matching_tags,
263
+ embedding_str,
264
+ tag_count,
265
+ limit
266
+ ]
267
+
268
+ ActiveRecord::Base.connection.select_all(
269
+ ActiveRecord::Base.sanitize_sql_array([sql, *params])
270
+ )
271
+ end
272
+
273
+ # Hybrid search without tag matching (fallback)
274
+ #
275
+ # @param query [String] Search query
276
+ # @param embedding_str [String] Sanitized embedding string
277
+ # @param additional_sql_bare [String] Additional SQL conditions (no alias)
278
+ # @param prefilter_limit [Integer] Candidates to consider
279
+ # @param limit [Integer] Maximum results
280
+ # @return [ActiveRecord::Result] Query results
281
+ #
282
+ def search_hybrid_without_tags(query:, embedding_str:, additional_sql_bare:, prefilter_limit:, limit:)
283
+ # No matching tags, fall back to standard hybrid (fulltext + vector)
284
+ # Include nodes without embeddings with a default similarity score
285
+ # Optimized: compute similarity once in CTE, reuse for combined_score
286
+ sql = <<~SQL
287
+ WITH candidates AS (
288
+ SELECT id, content, access_count, created_at, token_count, embedding
289
+ FROM nodes
290
+ WHERE deleted_at IS NULL
291
+ AND to_tsvector('english', content) @@ plainto_tsquery('english', ?)
292
+ #{additional_sql_bare}
293
+ LIMIT ?
294
+ ),
295
+ scored AS (
296
+ SELECT id, content, access_count, created_at, token_count,
297
+ CASE
298
+ WHEN embedding IS NOT NULL THEN 1 - (embedding <=> ?::vector)
299
+ ELSE 0.5
300
+ END as similarity
301
+ FROM candidates
302
+ )
303
+ SELECT id, content, access_count, created_at, token_count,
304
+ similarity,
305
+ 0.0 as tag_boost,
306
+ similarity as combined_score
307
+ FROM scored
308
+ ORDER BY combined_score DESC
309
+ LIMIT ?
310
+ SQL
311
+
312
+ ActiveRecord::Base.connection.select_all(
313
+ ActiveRecord::Base.sanitize_sql_array([
314
+ sql,
315
+ query,
316
+ prefilter_limit,
317
+ embedding_str,
318
+ limit
319
+ ])
320
+ )
321
+ end
322
+ end
323
+ end
324
+ end
@@ -0,0 +1,209 @@
1
+ # frozen_string_literal: true
2
+
3
+ class HTM
4
+ class LongTermMemory
5
+ # Node CRUD operations for LongTermMemory
6
+ #
7
+ # Handles creating, reading, updating, and deleting memory nodes with:
8
+ # - Content deduplication via SHA-256 hash
9
+ # - Soft delete restoration on duplicate content
10
+ # - Robot-node linking with remember tracking
11
+ # - Bulk access tracking
12
+ #
13
+ module NodeOperations
14
+ # Add a node to long-term memory (with deduplication)
15
+ #
16
+ # If content already exists (by content_hash), links the robot to the existing
17
+ # node and updates timestamps. Otherwise creates a new node.
18
+ #
19
+ # @param content [String] Conversation message/utterance
20
+ # @param token_count [Integer] Token count
21
+ # @param robot_id [Integer] Robot identifier
22
+ # @param embedding [Array<Float>, nil] Pre-generated embedding vector
23
+ # @param metadata [Hash] Flexible metadata for the node (default: {})
24
+ # @return [Hash] { node_id:, is_new:, robot_node: }
25
+ # @raise [ArgumentError] If metadata is not a Hash
26
+ #
27
+ def add(content:, token_count: 0, robot_id:, embedding: nil, metadata: {})
28
+ # Validate metadata parameter
29
+ unless metadata.is_a?(Hash)
30
+ raise ArgumentError, "metadata must be a Hash, got #{metadata.class}"
31
+ end
32
+ content_hash = HTM::Models::Node.generate_content_hash(content)
33
+
34
+ # Wrap in transaction to ensure data consistency
35
+ ActiveRecord::Base.transaction do
36
+ # Check for existing node with same content (including soft-deleted)
37
+ # This avoids unique constraint violations on content_hash
38
+ existing_node = HTM::Models::Node.with_deleted.find_by(content_hash: content_hash)
39
+
40
+ # If found but soft-deleted, restore it
41
+ if existing_node&.deleted?
42
+ existing_node.restore!
43
+ HTM.logger.info "Restored soft-deleted node #{existing_node.id} for content match"
44
+ end
45
+
46
+ if existing_node
47
+ # Link robot to existing node (or update if already linked)
48
+ robot_node = link_robot_to_node(robot_id: robot_id, node: existing_node)
49
+
50
+ # Update the node's updated_at timestamp
51
+ existing_node.touch
52
+
53
+ {
54
+ node_id: existing_node.id,
55
+ is_new: false,
56
+ robot_node: robot_node
57
+ }
58
+ else
59
+ # Prepare embedding if provided
60
+ embedding_str = nil
61
+ if embedding
62
+ # Use centralized padding and sanitization
63
+ padded_embedding = HTM::SqlBuilder.pad_embedding(embedding)
64
+ embedding_str = HTM::SqlBuilder.sanitize_embedding(padded_embedding)
65
+ end
66
+
67
+ # Create new node
68
+ node = HTM::Models::Node.create!(
69
+ content: content,
70
+ content_hash: content_hash,
71
+ token_count: token_count,
72
+ embedding: embedding_str,
73
+ metadata: metadata
74
+ )
75
+
76
+ # Link robot to new node
77
+ robot_node = link_robot_to_node(robot_id: robot_id, node: node)
78
+
79
+ # Selectively invalidate search-related cache entries only
80
+ # (preserves unrelated cached data like tag queries)
81
+ @cache&.invalidate_methods!(:search, :fulltext, :hybrid)
82
+
83
+ {
84
+ node_id: node.id,
85
+ is_new: true,
86
+ robot_node: robot_node
87
+ }
88
+ end
89
+ end
90
+ end
91
+
92
+ # Link a robot to a node (create or update robot_node record)
93
+ #
94
+ # @param robot_id [Integer] Robot ID
95
+ # @param node [HTM::Models::Node] Node to link
96
+ # @param working_memory [Boolean] Whether node is in working memory (default: false)
97
+ # @return [HTM::Models::RobotNode] The robot_node link record
98
+ #
99
+ def link_robot_to_node(robot_id:, node:, working_memory: false)
100
+ robot_node = HTM::Models::RobotNode.find_by(robot_id: robot_id, node_id: node.id)
101
+
102
+ if robot_node
103
+ # Existing link - record that robot remembered this again
104
+ robot_node.record_remember!
105
+ robot_node.update!(working_memory: working_memory) if working_memory
106
+ else
107
+ # New link
108
+ robot_node = HTM::Models::RobotNode.create!(
109
+ robot_id: robot_id,
110
+ node_id: node.id,
111
+ first_remembered_at: Time.current,
112
+ last_remembered_at: Time.current,
113
+ remember_count: 1,
114
+ working_memory: working_memory
115
+ )
116
+ end
117
+
118
+ robot_node
119
+ end
120
+
121
+ # Retrieve a node by ID
122
+ #
123
+ # Automatically tracks access by incrementing access_count and updating last_accessed.
124
+ # Uses a single UPDATE query instead of separate increment! and touch calls.
125
+ #
126
+ # @param node_id [Integer] Node database ID
127
+ # @return [Hash, nil] Node data or nil
128
+ #
129
+ def retrieve(node_id)
130
+ node = HTM::Models::Node.find_by(id: node_id)
131
+ return nil unless node
132
+
133
+ # Track access in a single UPDATE query (instead of separate increment! and touch)
134
+ node.update_columns(
135
+ access_count: node.access_count + 1,
136
+ last_accessed: Time.current
137
+ )
138
+
139
+ # Reload to get updated values
140
+ node.reload.attributes
141
+ end
142
+
143
+ # Update last_accessed timestamp
144
+ #
145
+ # @param node_id [Integer] Node database ID
146
+ # @return [void]
147
+ #
148
+ def update_last_accessed(node_id)
149
+ node = HTM::Models::Node.find_by(id: node_id)
150
+ node&.update(last_accessed: Time.current)
151
+ end
152
+
153
+ # Delete a node
154
+ #
155
+ # @param node_id [Integer] Node database ID
156
+ # @return [void]
157
+ #
158
+ def delete(node_id)
159
+ node = HTM::Models::Node.find_by(id: node_id)
160
+ node&.destroy
161
+
162
+ # Selectively invalidate search-related cache entries only
163
+ @cache&.invalidate_methods!(:search, :fulltext, :hybrid)
164
+ end
165
+
166
+ # Check if a node exists
167
+ #
168
+ # @param node_id [Integer] Node database ID
169
+ # @return [Boolean] True if node exists
170
+ #
171
+ def exists?(node_id)
172
+ HTM::Models::Node.exists?(node_id)
173
+ end
174
+
175
+ # Mark nodes as evicted from working memory
176
+ #
177
+ # Sets working_memory = false on the robot_nodes join table for the specified
178
+ # robot and node IDs.
179
+ #
180
+ # @param robot_id [Integer] Robot ID whose working memory is being evicted
181
+ # @param node_ids [Array<Integer>] Node IDs to mark as evicted
182
+ # @return [void]
183
+ #
184
+ def mark_evicted(robot_id:, node_ids:)
185
+ return if node_ids.empty?
186
+
187
+ HTM::Models::RobotNode
188
+ .where(robot_id: robot_id, node_id: node_ids)
189
+ .update_all(working_memory: false)
190
+ end
191
+
192
+ # Track access for multiple nodes (bulk operation)
193
+ #
194
+ # Updates access_count and last_accessed for all nodes in the array
195
+ #
196
+ # @param node_ids [Array<Integer>] Node IDs that were accessed
197
+ # @return [void]
198
+ #
199
+ def track_access(node_ids)
200
+ return if node_ids.empty?
201
+
202
+ # Atomic batch update
203
+ HTM::Models::Node.where(id: node_ids).update_all(
204
+ "access_count = access_count + 1, last_accessed = NOW()"
205
+ )
206
+ end
207
+ end
208
+ end
209
+ end