woods 1.6.1 → 2.0.0.beta2

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 (274) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +2035 -0
  3. data/CONTRIBUTING.md +253 -87
  4. data/README.md +161 -513
  5. data/SECURITY.md +92 -0
  6. data/assets/woods-wordmark-white-with-bg.png +0 -0
  7. data/docs/AGENT_GUIDE.md +204 -0
  8. data/docs/AGENT_SETUP.md +205 -0
  9. data/docs/BACKEND_MATRIX.md +470 -0
  10. data/docs/CONFIGURATION_REFERENCE.md +655 -0
  11. data/docs/CONSOLE_MCP_SETUP.md +829 -0
  12. data/docs/DOCKER_SETUP.md +454 -0
  13. data/docs/EMBEDDING_MODELS.md +136 -0
  14. data/docs/EVALUATION.md +91 -0
  15. data/docs/EXTRACTOR_REFERENCE.md +765 -0
  16. data/docs/FAQ.md +544 -0
  17. data/docs/GETTING_STARTED.md +183 -0
  18. data/docs/INCREMENTAL_EXTRACTION.md +455 -0
  19. data/docs/INTERNALS.md +418 -0
  20. data/docs/MCP_HTTP_TRANSPORT.md +144 -0
  21. data/docs/MCP_SERVERS.md +231 -0
  22. data/docs/MCP_TOOL_COOKBOOK.md +987 -0
  23. data/docs/MCP_WORKTREE_SETUP.md +127 -0
  24. data/docs/NOTION_INTEGRATION.md +283 -0
  25. data/docs/OBSIDIAN_INTEGRATION.md +170 -0
  26. data/docs/PUBLISHED_INDEX.md +213 -0
  27. data/docs/README.md +94 -0
  28. data/docs/RETRIEVAL_GUIDE.md +267 -0
  29. data/docs/TOKEN_BENCHMARK.md +68 -0
  30. data/docs/TROUBLESHOOTING.md +841 -0
  31. data/docs/UNBLOCKED_INTEGRATION.md +279 -0
  32. data/docs/UPGRADING_TO_2.md +321 -0
  33. data/docs/WATCH_DAEMON.md +667 -0
  34. data/docs/WHY_WOODS.md +219 -0
  35. data/exe/woods-console +40 -4
  36. data/exe/woods-console-mcp +21 -35
  37. data/exe/woods-mcp +20 -7
  38. data/exe/woods-mcp-http +80 -11
  39. data/exe/woods-mcp-start +57 -52
  40. data/lib/generators/woods/install_generator.rb +6 -5
  41. data/lib/generators/woods/pgvector_generator.rb +6 -3
  42. data/lib/generators/woods/templates/add_pgvector_to_woods.rb.erb +29 -9
  43. data/lib/generators/woods/templates/create_woods_tables.rb.erb +5 -1
  44. data/lib/generators/woods/templates/woods.rb.tt +49 -28
  45. data/lib/tasks/woods.rake +622 -168
  46. data/lib/tasks/woods_checks.rake +107 -0
  47. data/lib/tasks/woods_evaluation.rake +164 -80
  48. data/lib/woods/ast/call_site_extractor.rb +6 -15
  49. data/lib/woods/ast/method_extractor.rb +19 -9
  50. data/lib/woods/ast/parser.rb +54 -8
  51. data/lib/woods/atomic_file.rb +171 -2
  52. data/lib/woods/builder.rb +310 -22
  53. data/lib/woods/cache/cache_middleware.rb +7 -2
  54. data/lib/woods/cache/cache_store.rb +9 -1
  55. data/lib/woods/cache/solid_cache_store.rb +6 -4
  56. data/lib/woods/change_set.rb +88 -0
  57. data/lib/woods/checks/generation_resolution.rb +34 -0
  58. data/lib/woods/checks/moved_messages.rb +186 -0
  59. data/lib/woods/chunking/semantic_chunker.rb +160 -18
  60. data/lib/woods/console/audit_logger.rb +12 -3
  61. data/lib/woods/console/bridge_protocol.rb +3 -16
  62. data/lib/woods/console/connection_manager.rb +51 -136
  63. data/lib/woods/console/dispatch_pipeline.rb +42 -12
  64. data/lib/woods/console/embedded_executor.rb +806 -149
  65. data/lib/woods/console/eval_guard.rb +27 -20
  66. data/lib/woods/console/input_contract.rb +78 -0
  67. data/lib/woods/console/model_validator.rb +29 -1
  68. data/lib/woods/console/rack_middleware.rb +65 -42
  69. data/lib/woods/console/redactor.rb +26 -8
  70. data/lib/woods/console/safe_context.rb +58 -10
  71. data/lib/woods/console/scope_predicate_parser.rb +41 -0
  72. data/lib/woods/console/server.rb +119 -247
  73. data/lib/woods/console/sql_noise_stripper.rb +125 -16
  74. data/lib/woods/console/sql_table_scanner.rb +82 -22
  75. data/lib/woods/console/sql_validator.rb +459 -29
  76. data/lib/woods/console/table_gate.rb +2 -2
  77. data/lib/woods/console/tool_specs.rb +463 -90
  78. data/lib/woods/console/tools/tier1.rb +1 -5
  79. data/lib/woods/console/tools/tier4.rb +18 -9
  80. data/lib/woods/coordination/lock_heartbeat.rb +103 -0
  81. data/lib/woods/coordination/pipeline_lock.rb +263 -53
  82. data/lib/woods/db/migrations/007_typed_snapshot_units.rb +45 -0
  83. data/lib/woods/db/migrator.rb +3 -9
  84. data/lib/woods/db/schema_version.rb +47 -2
  85. data/lib/woods/dependency_graph.rb +898 -64
  86. data/lib/woods/embedding/fake.rb +138 -0
  87. data/lib/woods/embedding/indexer.rb +832 -40
  88. data/lib/woods/embedding/openai.rb +77 -19
  89. data/lib/woods/embedding/provider.rb +189 -11
  90. data/lib/woods/embedding/text_preparer.rb +1 -1
  91. data/lib/woods/embedding/token_counter.rb +0 -7
  92. data/lib/woods/evaluation/ablation_agent_payload.rb +38 -0
  93. data/lib/woods/evaluation/ablation_executor.rb +67 -0
  94. data/lib/woods/evaluation/ablation_provenance.rb +38 -0
  95. data/lib/woods/evaluation/ablation_report_writer.rb +43 -0
  96. data/lib/woods/evaluation/ablation_runner.rb +173 -0
  97. data/lib/woods/evaluation/ablation_summary.rb +65 -0
  98. data/lib/woods/evaluation/ablation_task.rb +66 -0
  99. data/lib/woods/evaluation/ablation_task_set.rb +77 -0
  100. data/lib/woods/evaluation/ablation_timed_executor.rb +91 -0
  101. data/lib/woods/evaluation/ablation_worktree.rb +71 -0
  102. data/lib/woods/evaluation/baseline.rb +60 -0
  103. data/lib/woods/evaluation/baseline_runner.rb +11 -3
  104. data/lib/woods/evaluation/evaluator.rb +41 -8
  105. data/lib/woods/evaluation/query_set.rb +79 -13
  106. data/lib/woods/evaluation/report_generator.rb +20 -1
  107. data/lib/woods/export/unit_facts.rb +0 -11
  108. data/lib/woods/extracted_unit.rb +22 -63
  109. data/lib/woods/extractor.rb +2783 -238
  110. data/lib/woods/extractors/action_cable_extractor.rb +9 -4
  111. data/lib/woods/extractors/ast_source_extraction.rb +20 -2
  112. data/lib/woods/extractors/caching_extractor.rb +46 -12
  113. data/lib/woods/extractors/callback_analyzer.rb +39 -9
  114. data/lib/woods/extractors/component_discovery.rb +123 -0
  115. data/lib/woods/extractors/concern_extractor.rb +17 -3
  116. data/lib/woods/extractors/controller_extractor.rb +389 -29
  117. data/lib/woods/extractors/decorator_extractor.rb +7 -14
  118. data/lib/woods/extractors/engine_extractor.rb +53 -8
  119. data/lib/woods/extractors/event_extractor.rb +55 -4
  120. data/lib/woods/extractors/factory_extractor.rb +49 -11
  121. data/lib/woods/extractors/graphql_extractor.rb +162 -66
  122. data/lib/woods/extractors/i18n_extractor.rb +6 -1
  123. data/lib/woods/extractors/job_extractor.rb +51 -21
  124. data/lib/woods/extractors/lib_extractor.rb +23 -17
  125. data/lib/woods/extractors/line_neutralizer.rb +171 -0
  126. data/lib/woods/extractors/mailer_extractor.rb +9 -1
  127. data/lib/woods/extractors/manager_extractor.rb +19 -2
  128. data/lib/woods/extractors/migration_extractor.rb +22 -11
  129. data/lib/woods/extractors/model_extractor.rb +292 -57
  130. data/lib/woods/extractors/package_extractor.rb +154 -0
  131. data/lib/woods/extractors/phlex_extractor.rb +18 -3
  132. data/lib/woods/extractors/policy_extractor.rb +6 -5
  133. data/lib/woods/extractors/poro_extractor.rb +13 -14
  134. data/lib/woods/extractors/pundit_extractor.rb +3 -3
  135. data/lib/woods/extractors/rails_source_extractor.rb +24 -7
  136. data/lib/woods/extractors/rake_task_extractor.rb +158 -30
  137. data/lib/woods/extractors/reference_patterns.rb +38 -0
  138. data/lib/woods/extractors/route_extractor.rb +58 -2
  139. data/lib/woods/extractors/scheduled_job_extractor.rb +51 -35
  140. data/lib/woods/extractors/serializer_extractor.rb +3 -4
  141. data/lib/woods/extractors/service_extractor.rb +11 -1
  142. data/lib/woods/extractors/shared_dependency_scanner.rb +24 -34
  143. data/lib/woods/extractors/shared_utility_methods.rb +36 -6
  144. data/lib/woods/extractors/source_nesting.rb +560 -0
  145. data/lib/woods/extractors/state_machine_extractor.rb +30 -18
  146. data/lib/woods/extractors/test_mapping_extractor.rb +26 -9
  147. data/lib/woods/extractors/view_component_extractor.rb +28 -3
  148. data/lib/woods/extractors/view_engines/erb.rb +17 -3
  149. data/lib/woods/feedback/gap_detector.rb +9 -3
  150. data/lib/woods/feedback/store.rb +7 -1
  151. data/lib/woods/filename_utils.rb +29 -1
  152. data/lib/woods/flow_analysis/operation_extractor.rb +22 -10
  153. data/lib/woods/flow_assembler.rb +147 -26
  154. data/lib/woods/flow_document.rb +1 -0
  155. data/lib/woods/flow_precomputer.rb +175 -22
  156. data/lib/woods/gem_mapper.rb +285 -0
  157. data/lib/woods/generation.rb +185 -0
  158. data/lib/woods/git_command.rb +38 -0
  159. data/lib/woods/git_provenance.rb +16 -2
  160. data/lib/woods/graph_analyzer.rb +564 -87
  161. data/lib/woods/index_artifact.rb +93 -23
  162. data/lib/woods/mcp/bearer_auth.rb +102 -13
  163. data/lib/woods/mcp/bootstrap_state.rb +77 -0
  164. data/lib/woods/mcp/bootstrapper.rb +582 -77
  165. data/lib/woods/mcp/config_resolver.rb +66 -6
  166. data/lib/woods/mcp/errors.rb +60 -0
  167. data/lib/woods/mcp/index_reader.rb +836 -117
  168. data/lib/woods/mcp/index_reader_pinning.rb +78 -0
  169. data/lib/woods/mcp/origin_guard.rb +66 -7
  170. data/lib/woods/mcp/protocol_policy.rb +98 -0
  171. data/lib/woods/mcp/provider_probe.rb +45 -6
  172. data/lib/woods/mcp/renderers/markdown_renderer.rb +72 -4
  173. data/lib/woods/mcp/renderers/plain_renderer.rb +54 -6
  174. data/lib/woods/mcp/server.rb +898 -152
  175. data/lib/woods/mcp/tasks/extension.rb +196 -0
  176. data/lib/woods/mcp/tasks/request_capture.rb +45 -0
  177. data/lib/woods/mcp/tasks/store.rb +518 -0
  178. data/lib/woods/mcp/tool_contract.rb +171 -0
  179. data/lib/woods/mcp/tool_response_renderer.rb +7 -0
  180. data/lib/woods/model_name_cache.rb +19 -1
  181. data/lib/woods/notion/client.rb +132 -36
  182. data/lib/woods/notion/exporter.rb +456 -61
  183. data/lib/woods/notion/mappers/column_mapper.rb +34 -5
  184. data/lib/woods/notion/mappers/migration_mapper.rb +32 -8
  185. data/lib/woods/notion/mappers/model_mapper.rb +21 -6
  186. data/lib/woods/notion/mappers/shared.rb +45 -3
  187. data/lib/woods/notion/sync_manifest.rb +258 -0
  188. data/lib/woods/obsidian/errors.rb +6 -0
  189. data/lib/woods/obsidian/name_mapper.rb +40 -24
  190. data/lib/woods/obsidian/vault_exporter.rb +103 -36
  191. data/lib/woods/operator/pipeline_guard.rb +118 -21
  192. data/lib/woods/operator/status_reporter.rb +20 -3
  193. data/lib/woods/path_dispatcher.rb +276 -0
  194. data/lib/woods/payload_store.rb +236 -0
  195. data/lib/woods/published_index/edge_shaper.rb +61 -0
  196. data/lib/woods/published_index/generation_catalog.rb +72 -0
  197. data/lib/woods/published_index/typed_unit_reader.rb +48 -0
  198. data/lib/woods/published_index.rb +287 -0
  199. data/lib/woods/railtie.rb +69 -30
  200. data/lib/woods/railtie_support.rb +167 -0
  201. data/lib/woods/release.rb +12 -0
  202. data/lib/woods/reload_policy.rb +206 -0
  203. data/lib/woods/resilience/circuit_breaker.rb +47 -8
  204. data/lib/woods/resilience/index_validator.rb +296 -10
  205. data/lib/woods/resilience/retryable_provider.rb +71 -6
  206. data/lib/woods/resolved_config.rb +55 -11
  207. data/lib/woods/retrieval/context_assembler.rb +132 -40
  208. data/lib/woods/retrieval/query_classifier.rb +26 -8
  209. data/lib/woods/retrieval/ranker.rb +193 -28
  210. data/lib/woods/retrieval/search_executor.rb +206 -39
  211. data/lib/woods/retriever.rb +317 -71
  212. data/lib/woods/retry_after.rb +22 -2
  213. data/lib/woods/ruby_analyzer/class_analyzer.rb +10 -14
  214. data/lib/woods/ruby_analyzer/fqn_builder.rb +2 -0
  215. data/lib/woods/ruby_analyzer/mermaid_renderer.rb +14 -4
  216. data/lib/woods/ruby_analyzer/method_analyzer.rb +1 -1
  217. data/lib/woods/ruby_analyzer/trace_enricher.rb +3 -0
  218. data/lib/woods/ruby_analyzer.rb +21 -5
  219. data/lib/woods/session_tracer/file_store.rb +138 -19
  220. data/lib/woods/session_tracer/middleware.rb +1 -2
  221. data/lib/woods/session_tracer/redis_store.rb +122 -12
  222. data/lib/woods/session_tracer/session_flow_assembler.rb +57 -17
  223. data/lib/woods/session_tracer/session_flow_document.rb +56 -14
  224. data/lib/woods/session_tracer/solid_cache_coordination.rb +192 -0
  225. data/lib/woods/session_tracer/solid_cache_store.rb +560 -91
  226. data/lib/woods/session_tracer/store.rb +14 -1
  227. data/lib/woods/storage/metadata_store.rb +230 -26
  228. data/lib/woods/storage/pgvector.rb +180 -22
  229. data/lib/woods/storage/qdrant.rb +367 -41
  230. data/lib/woods/storage/snapshotter/metadata.rb +79 -16
  231. data/lib/woods/storage/snapshotter/vector.rb +128 -17
  232. data/lib/woods/storage/snapshotter.rb +23 -5
  233. data/lib/woods/storage/vector_store.rb +49 -8
  234. data/lib/woods/storage_identity.rb +28 -0
  235. data/lib/woods/tasks.rb +53 -2
  236. data/lib/woods/temporal/json_snapshot_store.rb +112 -42
  237. data/lib/woods/temporal/snapshot_store.rb +139 -42
  238. data/lib/woods/unblocked/client.rb +119 -17
  239. data/lib/woods/unblocked/document_builder.rb +34 -2
  240. data/lib/woods/unblocked/exporter.rb +63 -27
  241. data/lib/woods/unblocked/rate_limiter.rb +23 -9
  242. data/lib/woods/unblocked/sync_manifest.rb +16 -8
  243. data/lib/woods/update_check.rb +24 -1
  244. data/lib/woods/util/uuid5.rb +124 -0
  245. data/lib/woods/version.rb +1 -1
  246. data/lib/woods/watch/daemon.rb +1345 -0
  247. data/lib/woods/watch/listen_watcher.rb +81 -0
  248. data/lib/woods/watch/polling_watcher.rb +137 -0
  249. data/lib/woods/watch/status.rb +169 -0
  250. data/lib/woods/watch/tree_scan.rb +163 -0
  251. data/lib/woods/watch/watcher.rb +100 -0
  252. data/lib/woods.rb +138 -9
  253. data/plugin/.claude-plugin/plugin.json +18 -0
  254. data/plugin/hooks/hooks.json +29 -0
  255. data/plugin/hooks/woods-post-edit.sh +226 -0
  256. data/plugin/hooks/woods-session-start.sh +77 -0
  257. data/plugin/skills/woods-agent-enable/SKILL.md +51 -0
  258. data/plugin/skills/woods-diagnose/SKILL.md +75 -0
  259. data/plugin/skills/woods-investigate/SKILL.md +39 -0
  260. data/plugin/skills/woods-mcp-config/SKILL.md +101 -0
  261. data/plugin/skills/woods-setup/SKILL.md +99 -0
  262. metadata +134 -23
  263. data/lib/woods/console/adapters/cache_adapter.rb +0 -58
  264. data/lib/woods/console/adapters/good_job_adapter.rb +0 -33
  265. data/lib/woods/console/adapters/job_adapter.rb +0 -74
  266. data/lib/woods/console/adapters/sidekiq_adapter.rb +0 -33
  267. data/lib/woods/console/adapters/solid_queue_adapter.rb +0 -33
  268. data/lib/woods/console/bridge.rb +0 -210
  269. data/lib/woods/formatting/claude_adapter.rb +0 -98
  270. data/lib/woods/formatting/generic_adapter.rb +0 -56
  271. data/lib/woods/formatting/gpt_adapter.rb +0 -64
  272. data/lib/woods/notion/mapper.rb +0 -40
  273. data/lib/woods/observability/health_check.rb +0 -79
  274. data/lib/woods/observability/instrumentation.rb +0 -34
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative '../storage_identity'
4
+
3
5
  module Woods
4
6
  module Retrieval
5
7
  # SearchExecutor maps a query classification to a retrieval strategy and
@@ -26,7 +28,12 @@ module Woods
26
28
  #
27
29
  class SearchExecutor
28
30
  # A single search candidate with provenance tracking.
29
- Candidate = Struct.new(:identifier, :score, :source, :metadata, keyword_init: true)
31
+ #
32
+ # +matched_fields+ is populated only on the keyword path — the list of
33
+ # metadata field names whose values matched a query keyword. It feeds
34
+ # {Ranker#keyword_score} (0.25 per field, capped at 1.0). Defaults to
35
+ # nil for every other source (vector/graph/direct), which scores 0.0.
36
+ Candidate = Struct.new(:identifier, :score, :source, :metadata, :matched_fields, keyword_init: true)
30
37
 
31
38
  # The result of a search execution.
32
39
  ExecutionResult = Struct.new(:candidates, :strategy, :query, keyword_init: true)
@@ -60,7 +67,8 @@ module Woods
60
67
  # strategies push this down into the vector store's metadata
61
68
  # filter — used by {Retriever#retrieve} to rank-within-type when
62
69
  # the unfiltered global top-K had no candidate of the requested type.
63
- # Overrides the classifier-derived +target_type+ in filter construction.
70
+ # This explicit caller filter is the ONLY source of vector type
71
+ # filters — the classifier's +target_type+ never becomes one (#184).
64
72
  # @param strategy [Symbol, nil] Override the classifier-selected strategy.
65
73
  # {Retriever#within_type_fallback} passes +:vector+ here because the
66
74
  # vector path is the only one that honors +type_filter+; if the
@@ -79,7 +87,7 @@ module Woods
79
87
  )
80
88
 
81
89
  ExecutionResult.new(
82
- candidates: candidates.first(limit),
90
+ candidates: bounded_candidates(expand_graph_candidates(candidates), limit, strategy),
83
91
  strategy: strategy,
84
92
  query: query
85
93
  )
@@ -87,6 +95,23 @@ module Woods
87
95
 
88
96
  private
89
97
 
98
+ def expand_graph_candidates(candidates)
99
+ candidates.flat_map do |candidate|
100
+ next candidate unless %i[graph graph_expansion].include?(candidate.source)
101
+ next candidate if @metadata_store.find(candidate.identifier)
102
+
103
+ matches = @metadata_store.search(candidate.identifier).select do |record|
104
+ record['identifier'] == candidate.identifier
105
+ end
106
+ next candidate if matches.empty?
107
+
108
+ matches.map do |record|
109
+ Candidate.new(identifier: record['id'], score: candidate.score,
110
+ source: candidate.source, metadata: record)
111
+ end
112
+ end
113
+ end
114
+
90
115
  # Select the best retrieval strategy for a classification.
91
116
  #
92
117
  # @param classification [QueryClassifier::Classification]
@@ -126,7 +151,7 @@ module Woods
126
151
  def run_strategy(strategy, query:, classification:, limit:, type_filter: nil)
127
152
  case strategy
128
153
  when :vector
129
- execute_vector(query, classification: classification, limit: limit, type_filter: type_filter)
154
+ execute_vector(query, limit: limit, type_filter: type_filter)
130
155
  when :keyword
131
156
  execute_keyword(classification: classification, limit: limit)
132
157
  when :graph
@@ -140,10 +165,13 @@ module Woods
140
165
 
141
166
  # Vector strategy: embed the query and search by similarity.
142
167
  #
168
+ # Takes no classification — the vector path is filtered only by the
169
+ # caller's explicit +type_filter+; see {#build_vector_filters} (#184).
170
+ #
143
171
  # @return [Array<Candidate>]
144
- def execute_vector(query, classification:, limit:, type_filter: nil)
172
+ def execute_vector(query, limit:, type_filter: nil)
145
173
  query_vector = @embedding_provider.embed(query)
146
- filters = build_vector_filters(classification, type_filter: type_filter)
174
+ filters = build_vector_filters(type_filter)
147
175
 
148
176
  results = @vector_store.search(query_vector, limit: limit, filters: filters)
149
177
  results.map do |r|
@@ -153,8 +181,8 @@ module Woods
153
181
 
154
182
  # Keyword strategy: search metadata store by extracted keywords.
155
183
  #
156
- # Searches each keyword individually and merges results, keeping the
157
- # best score per identifier.
184
+ # Searches each keyword individually and merges results by identifier,
185
+ # scoring each merged hit by match quality (see {#keyword_match_score}).
158
186
  #
159
187
  # @return [Array<Candidate>]
160
188
  def execute_keyword(classification:, limit:)
@@ -165,33 +193,126 @@ module Woods
165
193
  rank_keyword_results(all_results, limit)
166
194
  end
167
195
 
168
- # Search each keyword individually and merge, keeping best score per ID.
196
+ # Search each keyword individually and merge by identifier, unioning
197
+ # +matched_fields+ across keywords, then score each merged hit once
198
+ # from its complete matched-field set (see {#keyword_match_score}).
169
199
  #
170
200
  # @param keywords [Array<String>]
171
- # @return [Hash<String, Hash>] id => { score:, metadata: }
201
+ # @return [Hash<String, Hash>] id => { score:, metadata:, matched_fields: }
172
202
  def merge_keyword_results(keywords)
173
203
  results_by_id = {}
174
204
  keywords.each do |keyword|
175
205
  results = @metadata_store.search(keyword)
176
- results.each_with_index do |r, index|
177
- id = r['id']
178
- score = 1.0 - (index.to_f / [results.size, 10].max)
179
- results_by_id[id] = { score: score, metadata: r } if !results_by_id[id] || score > results_by_id[id][:score]
206
+ results.each do |r|
207
+ merge_keyword_hit(results_by_id, r['id'], r, matched_fields_for(r, keyword))
180
208
  end
181
209
  end
182
- results_by_id
210
+ score_keyword_hits(results_by_id)
211
+ end
212
+
213
+ # Fold a single keyword hit into the merged results: matched fields
214
+ # union across keywords, metadata kept from the first hit seen.
215
+ #
216
+ # @param results_by_id [Hash<String, Hash>] Accumulator (mutated)
217
+ # @param id [String] Candidate identifier
218
+ # @param record [Hash] The metadata record returned by the store
219
+ # @param matched [Array<String>] Fields that matched this keyword
220
+ # @return [void]
221
+ def merge_keyword_hit(results_by_id, id, record, matched)
222
+ entry = results_by_id[id]
223
+ if entry.nil?
224
+ results_by_id[id] = { metadata: record, matched_fields: matched }
225
+ else
226
+ entry[:matched_fields] |= matched
227
+ end
228
+ end
229
+
230
+ # Score each merged hit by match QUALITY (its final matched-field
231
+ # count) once every keyword has been folded in, rather than by the
232
+ # metadata store's result-row position.
233
+ #
234
+ # The store's +#search+ has no ORDER BY (SQLite runs a LIKE scan;
235
+ # InMemory iterates a Hash), so row order is arbitrary — scoring by
236
+ # index made that arbitrary order the dominant ranking signal once
237
+ # fed into the Ranker at semantic weight 0.40, ahead of the keyword
238
+ # evidence it was meant to represent.
239
+ #
240
+ # @param results_by_id [Hash<String, Hash>] id => { metadata:, matched_fields: }
241
+ # @return [Hash<String, Hash>] Same shape, each entry gaining +score+
242
+ def score_keyword_hits(results_by_id)
243
+ results_by_id.transform_values do |entry|
244
+ entry.merge(score: keyword_match_score(entry[:matched_fields]))
245
+ end
246
+ end
247
+
248
+ # Weight per matched field — mirrors {Ranker#keyword_score}'s own
249
+ # 0.25-per-field scale so the two stages agree on what "many fields
250
+ # matched" means. A hit with zero matched fields (shouldn't happen —
251
+ # the store only returned the record because something matched) still
252
+ # scores a positive floor rather than 0, keeping every keyword score
253
+ # in the documented (0.0, 1.0] range.
254
+ #
255
+ # @param matched_fields [Array<String>]
256
+ # @return [Float] Always in (0.0, 1.0]
257
+ def keyword_match_score(matched_fields)
258
+ return KEYWORD_FIELD_SCORE_WEIGHT if matched_fields.empty?
259
+
260
+ [matched_fields.size * KEYWORD_FIELD_SCORE_WEIGHT, 1.0].min
261
+ end
262
+
263
+ # @see #keyword_match_score
264
+ KEYWORD_FIELD_SCORE_WEIGHT = 0.25
265
+ private_constant :KEYWORD_FIELD_SCORE_WEIGHT
266
+
267
+ # Metadata record fields never counted as keyword matches: +id+
268
+ # duplicates +identifier+ (the store injects it), and +updated_at+
269
+ # is bookkeeping.
270
+ KEYWORD_MATCH_SKIPPED_FIELDS = %w[id updated_at].freeze
271
+ private_constant :KEYWORD_MATCH_SKIPPED_FIELDS
272
+ # Approximate which fields of a metadata record matched a keyword.
273
+ #
274
+ # The metadata store's +search+ probes fields but does not report
275
+ # which ones hit (SQLite runs a LIKE over the JSON blob; InMemory
276
+ # scans serialized records) — so per-candidate matched fields are not
277
+ # recoverable from the store without changing its interface. Instead,
278
+ # the executor re-checks the returned record: every top-level field
279
+ # whose stringified value contains the keyword (case-insensitively,
280
+ # matching SQLite's ASCII LIKE semantics) counts as matched. This is
281
+ # the input to {Ranker#keyword_score}'s 0.25-per-field table.
282
+ #
283
+ # @param record [Hash] Metadata record from the store (string keys)
284
+ # @param keyword [String] The keyword that produced this record
285
+ # @return [Array<String>] Names of fields whose values matched
286
+ def matched_fields_for(record, keyword)
287
+ return [] unless record.is_a?(Hash)
288
+
289
+ needle = keyword.to_s.downcase
290
+ return [] if needle.empty?
291
+
292
+ record.filter_map do |field, value|
293
+ next if KEYWORD_MATCH_SKIPPED_FIELDS.include?(field.to_s)
294
+
295
+ field.to_s if value.to_s.downcase.include?(needle)
296
+ end
183
297
  end
184
298
 
185
299
  # Rank merged keyword results into Candidate objects.
186
300
  #
301
+ # Ties (equal matched-field counts) break on identifier, ascending —
302
+ # deterministic output instead of depending on the store's arbitrary
303
+ # row order for candidates {#keyword_match_score} can't distinguish.
304
+ #
187
305
  # @param results [Hash<String, Hash>]
188
306
  # @param limit [Integer]
189
307
  # @return [Array<Candidate>]
190
308
  def rank_keyword_results(results, limit)
191
309
  scored = results.map do |id, data|
192
- Candidate.new(identifier: id, score: data[:score], source: :keyword, metadata: data[:metadata])
310
+ matched = data[:matched_fields]
311
+ Candidate.new(identifier: id, score: data[:score], source: :keyword,
312
+ metadata: data[:metadata],
313
+ matched_fields: matched && !matched.empty? ? matched : nil)
193
314
  end
194
- scored.sort_by { |c| -c.score }.first(limit)
315
+ scored.sort_by { |c| [-c.score, c.identifier.to_s] }.first(limit)
195
316
  end
196
317
 
197
318
  # Graph strategy: find related units via dependency traversal.
@@ -206,13 +327,13 @@ module Woods
206
327
 
207
328
  seeds.each do |seed_id|
208
329
  # Forward dependencies
209
- deps = @graph_store.dependencies_of(seed_id)
330
+ deps = @graph_store.dependencies_of(StorageIdentity.identifier(seed_id))
210
331
  deps.each do |dep|
211
332
  candidates << Candidate.new(identifier: dep, score: 0.8, source: :graph, metadata: {})
212
333
  end
213
334
 
214
335
  # Reverse dependencies (dependents)
215
- dependents = @graph_store.dependents_of(seed_id)
336
+ dependents = @graph_store.dependents_of(StorageIdentity.identifier(seed_id))
216
337
  dependents.each do |dep|
217
338
  candidates << Candidate.new(identifier: dep, score: 0.7, source: :graph, metadata: {})
218
339
  end
@@ -224,19 +345,39 @@ module Woods
224
345
  deduplicate(candidates).first(limit)
225
346
  end
226
347
 
348
+ # How far past +limit+ to fetch from each source before handing the
349
+ # merged list to the Ranker's RRF fusion (#apply_rrf). A candidate
350
+ # found by two sources near either source's own cutoff needs BOTH
351
+ # hits to survive into the merged list for RRF to see them — fetching
352
+ # only +limit+ per source (the old behavior) starved that overlap.
353
+ # Bounded (not unbounded) so a large index doesn't turn one hybrid
354
+ # query into three full-index scans.
355
+ HYBRID_PER_SOURCE_LIMIT_MULTIPLIER = 3
356
+ private_constant :HYBRID_PER_SOURCE_LIMIT_MULTIPLIER
357
+
227
358
  # Hybrid strategy: combine vector, keyword, and graph expansion.
228
359
  #
360
+ # Deliberately does NOT deduplicate across sources — a candidate
361
+ # found by both vector and keyword search must reach the Ranker as
362
+ # two separate entries (one per source) so RRF (Ranker#apply_rrf)
363
+ # can accumulate their per-source rank-based scores. Collapsing to
364
+ # one entry per identifier here, before ranking, discarded that
365
+ # cross-source consensus entirely: "hybrid" degraded into plain
366
+ # concatenation. Deduplication now happens exactly once, inside
367
+ # RRF's merge, which is where cross-source agreement is computed.
368
+ #
229
369
  # @return [Array<Candidate>]
230
370
  def execute_hybrid(query, classification:, limit:, type_filter: nil)
371
+ per_source_limit = limit * HYBRID_PER_SOURCE_LIMIT_MULTIPLIER
372
+
231
373
  # Gather from all three sources
232
- vector_candidates = execute_vector(query, classification: classification, limit: limit,
233
- type_filter: type_filter)
234
- keyword_candidates = execute_keyword(classification: classification, limit: limit)
374
+ vector_candidates = execute_vector(query, limit: per_source_limit, type_filter: type_filter)
375
+ keyword_candidates = execute_keyword(classification: classification, limit: per_source_limit)
235
376
 
236
377
  # Graph expansion on top vector results
237
378
  graph_candidates = []
238
379
  vector_candidates.first(3).each do |candidate|
239
- deps = @graph_store.dependencies_of(candidate.identifier)
380
+ deps = @graph_store.dependencies_of(StorageIdentity.identifier(candidate.identifier.sub(/#chunk_\d+\z/, '')))
240
381
  deps.each do |dep|
241
382
  graph_candidates << Candidate.new(
242
383
  identifier: dep, score: 0.5, source: :graph_expansion, metadata: {}
@@ -244,8 +385,10 @@ module Woods
244
385
  end
245
386
  end
246
387
 
247
- all = vector_candidates + keyword_candidates + graph_candidates
248
- deduplicate(all).first(limit)
388
+ # Sorted (not deduplicated) so the caller's later `.first(limit)`
389
+ # keeps the strongest candidates across all sources instead of
390
+ # exhausting the budget on whichever source is concatenated first.
391
+ (vector_candidates + keyword_candidates + graph_candidates).sort_by { |c| -c.score }
249
392
  end
250
393
 
251
394
  # Direct strategy: look up specific identifiers from keywords.
@@ -285,24 +428,25 @@ module Woods
285
428
  candidates
286
429
  end
287
430
 
288
- # Build metadata filters for vector search based on classification
289
- # and an optional explicit type filter from the caller.
431
+ # Build metadata filters for vector search from the caller's
432
+ # explicit type filter.
290
433
  #
291
- # The caller's explicit +type_filter+ overrides classifier-derived
292
- # +target_type+ when both are present the caller opted into a
293
- # specific set of types and that intent beats a heuristic.
434
+ # The classifier-derived +target_type+ is deliberately NOT pushed
435
+ # down here (#184). It is a heuristic, and hard-filtering on it had
436
+ # no fallback a mainline query like "How do we get the current
437
+ # user?" classified target :route and the vector search excluded
438
+ # everything but route units. The heuristic still reaches the
439
+ # {Ranker} as the soft +type_match+ signal, which boosts matching
440
+ # types without excluding anything. A caller-supplied +type_filter+
441
+ # keeps hard-filter semantics: the caller opted into specific types,
442
+ # and {Retriever#retrieve} owns the within-type fallback for that path.
294
443
  #
295
- # @param classification [QueryClassifier::Classification]
296
444
  # @param type_filter [Array<String>, nil]
297
445
  # @return [Hash]
298
- def build_vector_filters(classification, type_filter: nil)
299
- filters = {}
300
- if type_filter && !type_filter.empty?
301
- filters[:type] = type_filter.map(&:to_s)
302
- elsif classification.target_type
303
- filters[:type] = classification.target_type.to_s
304
- end
305
- filters
446
+ def build_vector_filters(type_filter)
447
+ return {} if type_filter.nil? || type_filter.empty?
448
+
449
+ { type: type_filter.map(&:to_s) }
306
450
  end
307
451
 
308
452
  # Find seed identifiers from classification keywords via metadata search.
@@ -340,6 +484,29 @@ module Woods
340
484
  end
341
485
  best.values.sort_by { |c| -c.score }
342
486
  end
487
+
488
+ # Chunk-stripped unit identity, matching Ranker#base_identifier —
489
+ # hybrid truncation must treat `User#chunk_0` and `User` as one unit.
490
+ CHUNK_SUFFIX_PATTERN = /#chunk_\d+\z/
491
+ private_constant :CHUNK_SUFFIX_PATTERN
492
+
493
+ # Bound strategy output to +limit+ results. For hybrid, +limit+ counts
494
+ # UNIQUE units and cross-source duplicates of an already-kept unit ride
495
+ # along free: the Ranker's RRF consensus needs every source's entry for
496
+ # a unit, and a raw-score cut dropped the weaker source's duplicate
497
+ # exactly when the two sources' score scales disagreed.
498
+ def bounded_candidates(candidates, limit, strategy)
499
+ return candidates.first(limit) unless strategy == :hybrid
500
+
501
+ kept = {}
502
+ candidates.select do |candidate|
503
+ base = candidate.identifier.sub(CHUNK_SUFFIX_PATTERN, '')
504
+ next true if kept.key?(base)
505
+
506
+ kept[base] = true if kept.size < limit
507
+ kept.key?(base)
508
+ end
509
+ end
343
510
  end
344
511
  end
345
512
  end