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
@@ -30,6 +30,13 @@ module Woods
30
30
  diversity: 0.05
31
31
  }.freeze
32
32
 
33
+ # Chunked units embed as `Identifier#chunk_N`; metadata and the graph are
34
+ # keyed on the bare identifier. Mirrors the same constant in Retriever,
35
+ # Bootstrapper, ContextAssembler and Indexer — kept local to each, as
36
+ # they are, rather than introducing a shared home in a patch release.
37
+ CHUNK_SUFFIX_PATTERN = /#chunk_\d+\z/
38
+ private_constant :CHUNK_SUFFIX_PATTERN
39
+
33
40
  # RRF constant — balances rank position vs. absolute score.
34
41
  # Standard value from the original RRF paper (Cormack et al., 2009).
35
42
  RRF_K = 60
@@ -44,9 +51,16 @@ module Woods
44
51
 
45
52
  # Rank candidates by weighted signal scoring with diversity adjustment.
46
53
  #
54
+ # Returned candidates carry their final weighted score (0.0–1.0) in
55
+ # +score+ — NOT the raw retrieval score they arrived with. Downstream
56
+ # consumers ({ContextAssembler#assemble_section} re-sorts by +score+;
57
+ # source attributions report it) must agree with the ranked order, so
58
+ # the ranker rewrites the score the same way {#apply_rrf} always has.
59
+ #
47
60
  # @param candidates [Array<Candidate>] Search candidates from executor
48
61
  # @param classification [QueryClassifier::Classification] Query classification
49
- # @return [Array<Candidate>] Re-ranked candidates (best first)
62
+ # @return [Array<Candidate>] Re-ranked candidates (best first), scores
63
+ # rewritten to the final weighted score
50
64
  def rank(candidates, classification:)
51
65
  return [] if candidates.empty?
52
66
 
@@ -57,11 +71,53 @@ module Woods
57
71
  sorted = sorted_by_weighted_score(scored)
58
72
  apply_diversity_penalty(sorted)
59
73
 
60
- sorted.map { |item| item[:candidate] }
74
+ finalize_ranked(sorted)
75
+ end
76
+
77
+ # Drop the memoized PageRank rank-percentile map so the next {#rank}
78
+ # call recomputes it from the graph store.
79
+ #
80
+ # The map is memoized per Ranker instance ({#pagerank_importance_map}),
81
+ # so a Ranker outlives any change to the graph it was built against.
82
+ # The Index MCP reload path does NOT call this: the M7 build-then-swap
83
+ # transaction ({Woods::MCP::Bootstrapper.commit_reload!}) installs a
84
+ # whole fresh Pipeline — including a fresh Ranker with no memo — rather
85
+ # than mutating a live graph in place, and must keep doing so.
86
+ #
87
+ # This is therefore an API for direct embedders only: code that holds a
88
+ # Ranker itself and repoints its graph store without rebuilding the
89
+ # Ranker has to invalidate the memo here, or the ranker keeps scoring
90
+ # importance from the retired graph.
91
+ #
92
+ # @return [void]
93
+ def invalidate_pagerank_cache!
94
+ @pagerank_importance_map = nil
61
95
  end
62
96
 
63
97
  private
64
98
 
99
+ # Rebuild each ranked candidate with its final weighted score.
100
+ #
101
+ # Mirrors {#rebuild_rrf_candidates}: a fresh Candidate is built rather
102
+ # than mutating the executor's structs in place, so callers holding
103
+ # the pre-rank candidate list (e.g. RetrievalTrace consumers) keep
104
+ # their original scores.
105
+ #
106
+ # @param sorted [Array<Hash>] Scored items sorted by weighted_score
107
+ # @return [Array<Candidate>]
108
+ def finalize_ranked(sorted)
109
+ sorted.map do |item|
110
+ candidate = item[:candidate]
111
+ build_candidate(
112
+ identifier: candidate.identifier,
113
+ score: item[:weighted_score],
114
+ source: candidate.source,
115
+ metadata: candidate.metadata,
116
+ matched_fields: candidate.matched_fields
117
+ )
118
+ end
119
+ end
120
+
65
121
  # Check if candidates come from multiple retrieval sources.
66
122
  #
67
123
  # @param candidates [Array<Candidate>]
@@ -76,66 +132,160 @@ module Woods
76
132
  # Each source's candidates are ranked independently, then RRF
77
133
  # merges ranks into a single score.
78
134
  #
135
+ # Raw RRF values live on a tiny scale (~1/(k+1) ≈ 0.016 per source),
136
+ # while every other ranking signal is 0.0–1.0 — feeding them straight
137
+ # into the weighted sum as +semantic+ made the 0.40 weight contribute
138
+ # at most ~0.02 and let recency/importance spreads dominate any fused
139
+ # query. Min-max normalizing across the merged list restores the
140
+ # 0.0–1.0 scale (see {#normalize_scores}); the single-source path is
141
+ # untouched because provider similarity scores already arrive 0–1.
142
+ #
79
143
  # @param candidates [Array<Candidate>]
80
- # @return [Array<Candidate>] Merged candidates with RRF scores
144
+ # @return [Array<Candidate>] Merged candidates with normalized RRF scores
81
145
  def apply_rrf(candidates)
82
- rrf_scores, metadata_map = compute_rrf_scores(candidates)
83
- rebuild_rrf_candidates(candidates, rrf_scores, metadata_map)
146
+ rrf_scores, metadata_map, matched_fields_map = compute_rrf_scores(candidates)
147
+ rebuild_rrf_candidates(candidates, normalize_scores(rrf_scores), metadata_map, matched_fields_map)
84
148
  end
85
149
 
86
150
  # Compute RRF scores across all sources.
87
151
  #
88
- # @return [Array(Hash, Hash)] [rrf_scores, metadata_map]
152
+ # Keyed on the chunk-stripped BASE identifier ({#base_identifier}),
153
+ # not the raw candidate identifier: a chunked unit's vector hit
154
+ # arrives as `Identifier#chunk_N` while its keyword hit arrives as
155
+ # the bare `Identifier` — keying on the raw id treated those as two
156
+ # unrelated identifiers and they never fused, so chunked corpora
157
+ # (rails_source-heavy hosts) got zero RRF benefit from hybrid search.
158
+ #
159
+ # Also accumulates per-identifier metadata (first non-empty wins) and
160
+ # matched fields (union across sources) so the merged candidate keeps
161
+ # the keyword signal — dropping +matched_fields+ here would kill
162
+ # {#keyword_score} on exactly the multi-source (hybrid) path where
163
+ # keyword evidence matters most.
164
+ #
165
+ # @return [Array(Hash, Hash, Hash)] [rrf_scores, metadata_map, matched_fields_map]
89
166
  def compute_rrf_scores(candidates)
90
167
  rrf_scores = Hash.new(0.0)
91
168
  metadata_map = {}
169
+ matched_fields_map = {}
92
170
 
93
171
  candidates.group_by(&:source).each_value do |source_candidates|
94
172
  ranked = source_candidates.sort_by { |c| -c.score }
95
173
  ranked.each_with_index do |candidate, idx|
174
+ base_id = base_identifier(candidate.identifier)
96
175
  # RRF is 1-based (Cormack et al., 2009): top-ranked doc uses rank 1, not 0.
97
- rrf_scores[candidate.identifier] += 1.0 / (RRF_K + idx + 1)
98
- metadata_map[candidate.identifier] ||= candidate.metadata
176
+ rrf_scores[base_id] += 1.0 / (RRF_K + idx + 1)
177
+ # `||=` alone is wrong here: a graph-expansion candidate's `{}`
178
+ # is truthy, so it would permanently shadow a real metadata hash
179
+ # arriving from a later-processed source. Only lock in a value
180
+ # once it's actually non-empty.
181
+ existing = metadata_map[base_id]
182
+ metadata_map[base_id] = candidate.metadata if existing.nil? || existing.empty?
183
+ merge_matched_fields(matched_fields_map, base_id, candidate)
99
184
  end
100
185
  end
101
186
 
102
- [rrf_scores, metadata_map]
187
+ [rrf_scores, metadata_map, matched_fields_map]
188
+ end
189
+
190
+ # Union a candidate's matched fields into the per-identifier map.
191
+ #
192
+ # @param matched_fields_map [Hash{String => Array<String>}] Accumulator (mutated)
193
+ # @param identifier [String] Chunk-stripped base identifier to accumulate under
194
+ # @param candidate [Candidate]
195
+ # @return [void]
196
+ def merge_matched_fields(matched_fields_map, identifier, candidate)
197
+ fields = candidate.respond_to?(:matched_fields) ? candidate.matched_fields : nil
198
+ return unless fields
199
+
200
+ matched_fields_map[identifier] = (matched_fields_map[identifier] || []) | fields
103
201
  end
104
202
 
105
- # Rebuild candidates with merged RRF scores.
203
+ # Min-max normalize a score map to the 0.0–1.0 range.
204
+ #
205
+ # The degenerate all-equal case (including a single candidate) maps
206
+ # everything to 1.0 — an identifier every source agreed on is a full-
207
+ # strength semantic match, not a zero.
208
+ #
209
+ # @param scores [Hash{String => Float}]
210
+ # @return [Hash{String => Float}]
211
+ def normalize_scores(scores)
212
+ min, max = scores.values.minmax
213
+ spread = max - min
214
+ return scores.transform_values { 1.0 } if spread.zero?
215
+
216
+ scores.transform_values { |score| (score - min) / spread }
217
+ end
218
+
219
+ # Rebuild candidates with merged (normalized) RRF scores.
220
+ #
221
+ # +identifier+ in +rrf_scores+ is already the chunk-stripped base
222
+ # identifier (see {#compute_rrf_scores}) — the merged Candidate is
223
+ # built under that base id, which is also what {#score_candidates}
224
+ # looks metadata up by, so a fused chunk/base pair scores and
225
+ # displays as the single unit it represents.
106
226
  #
107
227
  # @return [Array<Candidate>]
108
- def rebuild_rrf_candidates(candidates, rrf_scores, metadata_map)
109
- # Plain-Ruby `index_by` substitute — the ActiveSupport version
110
- # isn't loaded when the gem runs outside a Rails boot. Preserve
111
- # last-wins semantics to match ActiveSupport's `Enumerable#index_by`
112
- # so the merged candidate's `source` continues to reflect the
113
- # final source a given identifier appeared in (relevant when
114
- # observability/debug tools read `.source` on an RRF result).
115
- original_by_id = {}
116
- candidates.each { |c| original_by_id[c.identifier] = c }
228
+ def rebuild_rrf_candidates(candidates, rrf_scores, metadata_map, matched_fields_map)
229
+ original_by_id = pick_merged_source(candidates)
117
230
  rrf_scores.sort_by { |_id, score| -score }.map do |identifier, score|
118
231
  original = original_by_id[identifier]
119
232
  build_candidate(
120
233
  identifier: identifier,
121
234
  score: score,
122
235
  source: original&.source || :rrf,
123
- metadata: metadata_map[identifier]
236
+ metadata: metadata_map[identifier],
237
+ matched_fields: matched_fields_map[identifier]
124
238
  )
125
239
  end
126
240
  end
127
241
 
242
+ # Pick the representative candidate (for +source+ attribution) per
243
+ # base identifier across every source that hit it.
244
+ #
245
+ # +:graph_expansion+ never wins this pick over a vector/keyword/direct
246
+ # duplicate: a unit strongly hit by vector search AND reached via
247
+ # graph expansion is a real primary result, not incidental context,
248
+ # and {ContextAssembler#add_result_sections} routes solely on
249
+ # +candidate.source+ to decide primary vs. supporting placement.
250
+ # Plain last-wins (the prior behavior, matching ActiveSupport's
251
+ # +Enumerable#index_by+) let whichever duplicate the source-grouping
252
+ # loop visited last silently demote it. Within a precedence tier,
253
+ # last-wins is preserved.
254
+ #
255
+ # @param candidates [Array<Candidate>]
256
+ # @return [Hash{String => Candidate}]
257
+ def pick_merged_source(candidates)
258
+ original_by_id = {}
259
+ candidates.each do |c|
260
+ id = base_identifier(c.identifier)
261
+ existing = original_by_id[id]
262
+ next if existing && existing.source != :graph_expansion && c.source == :graph_expansion
263
+
264
+ original_by_id[id] = c
265
+ end
266
+ original_by_id
267
+ end
268
+
128
269
  # Score each candidate across all signals.
129
270
  #
130
271
  # @param candidates [Array<Candidate>]
131
272
  # @param classification [QueryClassifier::Classification]
132
273
  # @return [Array<Hash>]
133
274
  def score_candidates(candidates, classification)
134
- # Batch-fetch all metadata in one query instead of per-candidate lookups
135
- unit_map = @metadata_store.find_batch(candidates.map(&:identifier))
275
+ # Look up by *base* identifier. A chunked unit's vector ids carry a
276
+ # `#chunk_N` suffix, but the metadata store and the PageRank map are
277
+ # both keyed on the unit — so passing the raw identifier missed every
278
+ # chunk, and recency, importance, type_match and diversity all fell
279
+ # back to their neutral values precisely on chunked corpora
280
+ # (rails_source-heavy hosts), leaving semantic+keyword to decide alone.
281
+ # The bootstrapper and the assembler already strip; the ranker was the
282
+ # one consumer that did neither.
283
+ base_ids = candidates.to_h { |candidate| [candidate.identifier, base_identifier(candidate.identifier)] }
284
+ unit_map = @metadata_store.find_batch(base_ids.values.uniq)
136
285
 
137
286
  candidates.map do |candidate|
138
- unit = unit_map[candidate.identifier]
287
+ base_id = base_ids[candidate.identifier]
288
+ unit = unit_map[base_id]
139
289
 
140
290
  {
141
291
  candidate: candidate,
@@ -144,7 +294,7 @@ module Woods
144
294
  semantic: candidate.score.to_f,
145
295
  keyword: keyword_score(candidate),
146
296
  recency: recency_score(unit),
147
- importance: importance_score(unit, candidate.identifier),
297
+ importance: importance_score(unit, base_id),
148
298
  type_match: type_match_score(unit, classification),
149
299
  diversity: 1.0 # Adjusted after initial sort
150
300
  }
@@ -193,6 +343,14 @@ module Woods
193
343
  end
194
344
  end
195
345
 
346
+ # The unit identifier behind a possibly chunk-suffixed candidate id.
347
+ #
348
+ # @param identifier [String]
349
+ # @return [String]
350
+ def base_identifier(identifier)
351
+ identifier.to_s.sub(CHUNK_SUFFIX_PATTERN, '')
352
+ end
353
+
196
354
  # Importance score based on PageRank / structural importance.
197
355
  #
198
356
  # Prefers live PageRank from the graph store (rank-percentile 0.0–1.0) when
@@ -204,7 +362,7 @@ module Woods
204
362
  # @param identifier [String] Candidate identifier (matched against PageRank keys)
205
363
  # @return [Float] 0.0 to 1.0
206
364
  def importance_score(unit, identifier)
207
- pagerank = pagerank_importance_map[identifier]
365
+ pagerank = pagerank_importance_map[(unit && (unit['identifier'] || unit[:identifier])) || identifier]
208
366
  return pagerank if pagerank
209
367
 
210
368
  return 0.5 unless unit
@@ -231,6 +389,12 @@ module Woods
231
389
 
232
390
  # Compute rank-percentile scores from the graph store's PageRank hash.
233
391
  #
392
+ # `respond_to?` alone is the wrong guard: {Storage::GraphStore::Interface}
393
+ # *defines* +#pagerank+ as a +NotImplementedError+ stub, so an adapter
394
+ # that merely includes the interface without overriding it still
395
+ # answers +respond_to?+ with +true+ (B-108) — and +NotImplementedError+
396
+ # is a +ScriptError+, which the rescue below does not catch on its own.
397
+ #
234
398
  # @return [Hash{String => Float}] Empty hash when no graph store or no scores.
235
399
  def compute_pagerank_importance_map
236
400
  return {} unless @graph_store.respond_to?(:pagerank)
@@ -243,7 +407,7 @@ module Woods
243
407
  ranked.each_with_index.to_h do |(identifier, _score), rank|
244
408
  [identifier, 1.0 - (rank / total)]
245
409
  end
246
- rescue StandardError
410
+ rescue StandardError, NotImplementedError
247
411
  {}
248
412
  end
249
413
 
@@ -351,12 +515,13 @@ module Woods
351
515
  # Build a Candidate struct compatible with SearchExecutor::Candidate.
352
516
  #
353
517
  # @return [Candidate-like Struct]
354
- def build_candidate(identifier:, score:, source:, metadata:)
518
+ def build_candidate(identifier:, score:, source:, metadata:, matched_fields: nil)
355
519
  SearchExecutor::Candidate.new(
356
520
  identifier: identifier,
357
521
  score: score,
358
522
  source: source,
359
- metadata: metadata
523
+ metadata: metadata,
524
+ matched_fields: matched_fields
360
525
  )
361
526
  end
362
527
  end