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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +2035 -0
- data/CONTRIBUTING.md +253 -87
- data/README.md +161 -513
- data/SECURITY.md +92 -0
- data/assets/woods-wordmark-white-with-bg.png +0 -0
- data/docs/AGENT_GUIDE.md +204 -0
- data/docs/AGENT_SETUP.md +205 -0
- data/docs/BACKEND_MATRIX.md +470 -0
- data/docs/CONFIGURATION_REFERENCE.md +655 -0
- data/docs/CONSOLE_MCP_SETUP.md +829 -0
- data/docs/DOCKER_SETUP.md +454 -0
- data/docs/EMBEDDING_MODELS.md +136 -0
- data/docs/EVALUATION.md +91 -0
- data/docs/EXTRACTOR_REFERENCE.md +765 -0
- data/docs/FAQ.md +544 -0
- data/docs/GETTING_STARTED.md +183 -0
- data/docs/INCREMENTAL_EXTRACTION.md +455 -0
- data/docs/INTERNALS.md +418 -0
- data/docs/MCP_HTTP_TRANSPORT.md +144 -0
- data/docs/MCP_SERVERS.md +231 -0
- data/docs/MCP_TOOL_COOKBOOK.md +987 -0
- data/docs/MCP_WORKTREE_SETUP.md +127 -0
- data/docs/NOTION_INTEGRATION.md +283 -0
- data/docs/OBSIDIAN_INTEGRATION.md +170 -0
- data/docs/PUBLISHED_INDEX.md +213 -0
- data/docs/README.md +94 -0
- data/docs/RETRIEVAL_GUIDE.md +267 -0
- data/docs/TOKEN_BENCHMARK.md +68 -0
- data/docs/TROUBLESHOOTING.md +841 -0
- data/docs/UNBLOCKED_INTEGRATION.md +279 -0
- data/docs/UPGRADING_TO_2.md +321 -0
- data/docs/WATCH_DAEMON.md +667 -0
- data/docs/WHY_WOODS.md +219 -0
- data/exe/woods-console +40 -4
- data/exe/woods-console-mcp +21 -35
- data/exe/woods-mcp +20 -7
- data/exe/woods-mcp-http +80 -11
- data/exe/woods-mcp-start +57 -52
- data/lib/generators/woods/install_generator.rb +6 -5
- data/lib/generators/woods/pgvector_generator.rb +6 -3
- data/lib/generators/woods/templates/add_pgvector_to_woods.rb.erb +29 -9
- data/lib/generators/woods/templates/create_woods_tables.rb.erb +5 -1
- data/lib/generators/woods/templates/woods.rb.tt +49 -28
- data/lib/tasks/woods.rake +622 -168
- data/lib/tasks/woods_checks.rake +107 -0
- data/lib/tasks/woods_evaluation.rake +164 -80
- data/lib/woods/ast/call_site_extractor.rb +6 -15
- data/lib/woods/ast/method_extractor.rb +19 -9
- data/lib/woods/ast/parser.rb +54 -8
- data/lib/woods/atomic_file.rb +171 -2
- data/lib/woods/builder.rb +310 -22
- data/lib/woods/cache/cache_middleware.rb +7 -2
- data/lib/woods/cache/cache_store.rb +9 -1
- data/lib/woods/cache/solid_cache_store.rb +6 -4
- data/lib/woods/change_set.rb +88 -0
- data/lib/woods/checks/generation_resolution.rb +34 -0
- data/lib/woods/checks/moved_messages.rb +186 -0
- data/lib/woods/chunking/semantic_chunker.rb +160 -18
- data/lib/woods/console/audit_logger.rb +12 -3
- data/lib/woods/console/bridge_protocol.rb +3 -16
- data/lib/woods/console/connection_manager.rb +51 -136
- data/lib/woods/console/dispatch_pipeline.rb +42 -12
- data/lib/woods/console/embedded_executor.rb +806 -149
- data/lib/woods/console/eval_guard.rb +27 -20
- data/lib/woods/console/input_contract.rb +78 -0
- data/lib/woods/console/model_validator.rb +29 -1
- data/lib/woods/console/rack_middleware.rb +65 -42
- data/lib/woods/console/redactor.rb +26 -8
- data/lib/woods/console/safe_context.rb +58 -10
- data/lib/woods/console/scope_predicate_parser.rb +41 -0
- data/lib/woods/console/server.rb +119 -247
- data/lib/woods/console/sql_noise_stripper.rb +125 -16
- data/lib/woods/console/sql_table_scanner.rb +82 -22
- data/lib/woods/console/sql_validator.rb +459 -29
- data/lib/woods/console/table_gate.rb +2 -2
- data/lib/woods/console/tool_specs.rb +463 -90
- data/lib/woods/console/tools/tier1.rb +1 -5
- data/lib/woods/console/tools/tier4.rb +18 -9
- data/lib/woods/coordination/lock_heartbeat.rb +103 -0
- data/lib/woods/coordination/pipeline_lock.rb +263 -53
- data/lib/woods/db/migrations/007_typed_snapshot_units.rb +45 -0
- data/lib/woods/db/migrator.rb +3 -9
- data/lib/woods/db/schema_version.rb +47 -2
- data/lib/woods/dependency_graph.rb +898 -64
- data/lib/woods/embedding/fake.rb +138 -0
- data/lib/woods/embedding/indexer.rb +832 -40
- data/lib/woods/embedding/openai.rb +77 -19
- data/lib/woods/embedding/provider.rb +189 -11
- data/lib/woods/embedding/text_preparer.rb +1 -1
- data/lib/woods/embedding/token_counter.rb +0 -7
- data/lib/woods/evaluation/ablation_agent_payload.rb +38 -0
- data/lib/woods/evaluation/ablation_executor.rb +67 -0
- data/lib/woods/evaluation/ablation_provenance.rb +38 -0
- data/lib/woods/evaluation/ablation_report_writer.rb +43 -0
- data/lib/woods/evaluation/ablation_runner.rb +173 -0
- data/lib/woods/evaluation/ablation_summary.rb +65 -0
- data/lib/woods/evaluation/ablation_task.rb +66 -0
- data/lib/woods/evaluation/ablation_task_set.rb +77 -0
- data/lib/woods/evaluation/ablation_timed_executor.rb +91 -0
- data/lib/woods/evaluation/ablation_worktree.rb +71 -0
- data/lib/woods/evaluation/baseline.rb +60 -0
- data/lib/woods/evaluation/baseline_runner.rb +11 -3
- data/lib/woods/evaluation/evaluator.rb +41 -8
- data/lib/woods/evaluation/query_set.rb +79 -13
- data/lib/woods/evaluation/report_generator.rb +20 -1
- data/lib/woods/export/unit_facts.rb +0 -11
- data/lib/woods/extracted_unit.rb +22 -63
- data/lib/woods/extractor.rb +2783 -238
- data/lib/woods/extractors/action_cable_extractor.rb +9 -4
- data/lib/woods/extractors/ast_source_extraction.rb +20 -2
- data/lib/woods/extractors/caching_extractor.rb +46 -12
- data/lib/woods/extractors/callback_analyzer.rb +39 -9
- data/lib/woods/extractors/component_discovery.rb +123 -0
- data/lib/woods/extractors/concern_extractor.rb +17 -3
- data/lib/woods/extractors/controller_extractor.rb +389 -29
- data/lib/woods/extractors/decorator_extractor.rb +7 -14
- data/lib/woods/extractors/engine_extractor.rb +53 -8
- data/lib/woods/extractors/event_extractor.rb +55 -4
- data/lib/woods/extractors/factory_extractor.rb +49 -11
- data/lib/woods/extractors/graphql_extractor.rb +162 -66
- data/lib/woods/extractors/i18n_extractor.rb +6 -1
- data/lib/woods/extractors/job_extractor.rb +51 -21
- data/lib/woods/extractors/lib_extractor.rb +23 -17
- data/lib/woods/extractors/line_neutralizer.rb +171 -0
- data/lib/woods/extractors/mailer_extractor.rb +9 -1
- data/lib/woods/extractors/manager_extractor.rb +19 -2
- data/lib/woods/extractors/migration_extractor.rb +22 -11
- data/lib/woods/extractors/model_extractor.rb +292 -57
- data/lib/woods/extractors/package_extractor.rb +154 -0
- data/lib/woods/extractors/phlex_extractor.rb +18 -3
- data/lib/woods/extractors/policy_extractor.rb +6 -5
- data/lib/woods/extractors/poro_extractor.rb +13 -14
- data/lib/woods/extractors/pundit_extractor.rb +3 -3
- data/lib/woods/extractors/rails_source_extractor.rb +24 -7
- data/lib/woods/extractors/rake_task_extractor.rb +158 -30
- data/lib/woods/extractors/reference_patterns.rb +38 -0
- data/lib/woods/extractors/route_extractor.rb +58 -2
- data/lib/woods/extractors/scheduled_job_extractor.rb +51 -35
- data/lib/woods/extractors/serializer_extractor.rb +3 -4
- data/lib/woods/extractors/service_extractor.rb +11 -1
- data/lib/woods/extractors/shared_dependency_scanner.rb +24 -34
- data/lib/woods/extractors/shared_utility_methods.rb +36 -6
- data/lib/woods/extractors/source_nesting.rb +560 -0
- data/lib/woods/extractors/state_machine_extractor.rb +30 -18
- data/lib/woods/extractors/test_mapping_extractor.rb +26 -9
- data/lib/woods/extractors/view_component_extractor.rb +28 -3
- data/lib/woods/extractors/view_engines/erb.rb +17 -3
- data/lib/woods/feedback/gap_detector.rb +9 -3
- data/lib/woods/feedback/store.rb +7 -1
- data/lib/woods/filename_utils.rb +29 -1
- data/lib/woods/flow_analysis/operation_extractor.rb +22 -10
- data/lib/woods/flow_assembler.rb +147 -26
- data/lib/woods/flow_document.rb +1 -0
- data/lib/woods/flow_precomputer.rb +175 -22
- data/lib/woods/gem_mapper.rb +285 -0
- data/lib/woods/generation.rb +185 -0
- data/lib/woods/git_command.rb +38 -0
- data/lib/woods/git_provenance.rb +16 -2
- data/lib/woods/graph_analyzer.rb +564 -87
- data/lib/woods/index_artifact.rb +93 -23
- data/lib/woods/mcp/bearer_auth.rb +102 -13
- data/lib/woods/mcp/bootstrap_state.rb +77 -0
- data/lib/woods/mcp/bootstrapper.rb +582 -77
- data/lib/woods/mcp/config_resolver.rb +66 -6
- data/lib/woods/mcp/errors.rb +60 -0
- data/lib/woods/mcp/index_reader.rb +836 -117
- data/lib/woods/mcp/index_reader_pinning.rb +78 -0
- data/lib/woods/mcp/origin_guard.rb +66 -7
- data/lib/woods/mcp/protocol_policy.rb +98 -0
- data/lib/woods/mcp/provider_probe.rb +45 -6
- data/lib/woods/mcp/renderers/markdown_renderer.rb +72 -4
- data/lib/woods/mcp/renderers/plain_renderer.rb +54 -6
- data/lib/woods/mcp/server.rb +898 -152
- data/lib/woods/mcp/tasks/extension.rb +196 -0
- data/lib/woods/mcp/tasks/request_capture.rb +45 -0
- data/lib/woods/mcp/tasks/store.rb +518 -0
- data/lib/woods/mcp/tool_contract.rb +171 -0
- data/lib/woods/mcp/tool_response_renderer.rb +7 -0
- data/lib/woods/model_name_cache.rb +19 -1
- data/lib/woods/notion/client.rb +132 -36
- data/lib/woods/notion/exporter.rb +456 -61
- data/lib/woods/notion/mappers/column_mapper.rb +34 -5
- data/lib/woods/notion/mappers/migration_mapper.rb +32 -8
- data/lib/woods/notion/mappers/model_mapper.rb +21 -6
- data/lib/woods/notion/mappers/shared.rb +45 -3
- data/lib/woods/notion/sync_manifest.rb +258 -0
- data/lib/woods/obsidian/errors.rb +6 -0
- data/lib/woods/obsidian/name_mapper.rb +40 -24
- data/lib/woods/obsidian/vault_exporter.rb +103 -36
- data/lib/woods/operator/pipeline_guard.rb +118 -21
- data/lib/woods/operator/status_reporter.rb +20 -3
- data/lib/woods/path_dispatcher.rb +276 -0
- data/lib/woods/payload_store.rb +236 -0
- data/lib/woods/published_index/edge_shaper.rb +61 -0
- data/lib/woods/published_index/generation_catalog.rb +72 -0
- data/lib/woods/published_index/typed_unit_reader.rb +48 -0
- data/lib/woods/published_index.rb +287 -0
- data/lib/woods/railtie.rb +69 -30
- data/lib/woods/railtie_support.rb +167 -0
- data/lib/woods/release.rb +12 -0
- data/lib/woods/reload_policy.rb +206 -0
- data/lib/woods/resilience/circuit_breaker.rb +47 -8
- data/lib/woods/resilience/index_validator.rb +296 -10
- data/lib/woods/resilience/retryable_provider.rb +71 -6
- data/lib/woods/resolved_config.rb +55 -11
- data/lib/woods/retrieval/context_assembler.rb +132 -40
- data/lib/woods/retrieval/query_classifier.rb +26 -8
- data/lib/woods/retrieval/ranker.rb +193 -28
- data/lib/woods/retrieval/search_executor.rb +206 -39
- data/lib/woods/retriever.rb +317 -71
- data/lib/woods/retry_after.rb +22 -2
- data/lib/woods/ruby_analyzer/class_analyzer.rb +10 -14
- data/lib/woods/ruby_analyzer/fqn_builder.rb +2 -0
- data/lib/woods/ruby_analyzer/mermaid_renderer.rb +14 -4
- data/lib/woods/ruby_analyzer/method_analyzer.rb +1 -1
- data/lib/woods/ruby_analyzer/trace_enricher.rb +3 -0
- data/lib/woods/ruby_analyzer.rb +21 -5
- data/lib/woods/session_tracer/file_store.rb +138 -19
- data/lib/woods/session_tracer/middleware.rb +1 -2
- data/lib/woods/session_tracer/redis_store.rb +122 -12
- data/lib/woods/session_tracer/session_flow_assembler.rb +57 -17
- data/lib/woods/session_tracer/session_flow_document.rb +56 -14
- data/lib/woods/session_tracer/solid_cache_coordination.rb +192 -0
- data/lib/woods/session_tracer/solid_cache_store.rb +560 -91
- data/lib/woods/session_tracer/store.rb +14 -1
- data/lib/woods/storage/metadata_store.rb +230 -26
- data/lib/woods/storage/pgvector.rb +180 -22
- data/lib/woods/storage/qdrant.rb +367 -41
- data/lib/woods/storage/snapshotter/metadata.rb +79 -16
- data/lib/woods/storage/snapshotter/vector.rb +128 -17
- data/lib/woods/storage/snapshotter.rb +23 -5
- data/lib/woods/storage/vector_store.rb +49 -8
- data/lib/woods/storage_identity.rb +28 -0
- data/lib/woods/tasks.rb +53 -2
- data/lib/woods/temporal/json_snapshot_store.rb +112 -42
- data/lib/woods/temporal/snapshot_store.rb +139 -42
- data/lib/woods/unblocked/client.rb +119 -17
- data/lib/woods/unblocked/document_builder.rb +34 -2
- data/lib/woods/unblocked/exporter.rb +63 -27
- data/lib/woods/unblocked/rate_limiter.rb +23 -9
- data/lib/woods/unblocked/sync_manifest.rb +16 -8
- data/lib/woods/update_check.rb +24 -1
- data/lib/woods/util/uuid5.rb +124 -0
- data/lib/woods/version.rb +1 -1
- data/lib/woods/watch/daemon.rb +1345 -0
- data/lib/woods/watch/listen_watcher.rb +81 -0
- data/lib/woods/watch/polling_watcher.rb +137 -0
- data/lib/woods/watch/status.rb +169 -0
- data/lib/woods/watch/tree_scan.rb +163 -0
- data/lib/woods/watch/watcher.rb +100 -0
- data/lib/woods.rb +138 -9
- data/plugin/.claude-plugin/plugin.json +18 -0
- data/plugin/hooks/hooks.json +29 -0
- data/plugin/hooks/woods-post-edit.sh +226 -0
- data/plugin/hooks/woods-session-start.sh +77 -0
- data/plugin/skills/woods-agent-enable/SKILL.md +51 -0
- data/plugin/skills/woods-diagnose/SKILL.md +75 -0
- data/plugin/skills/woods-investigate/SKILL.md +39 -0
- data/plugin/skills/woods-mcp-config/SKILL.md +101 -0
- data/plugin/skills/woods-setup/SKILL.md +99 -0
- metadata +134 -23
- data/lib/woods/console/adapters/cache_adapter.rb +0 -58
- data/lib/woods/console/adapters/good_job_adapter.rb +0 -33
- data/lib/woods/console/adapters/job_adapter.rb +0 -74
- data/lib/woods/console/adapters/sidekiq_adapter.rb +0 -33
- data/lib/woods/console/adapters/solid_queue_adapter.rb +0 -33
- data/lib/woods/console/bridge.rb +0 -210
- data/lib/woods/formatting/claude_adapter.rb +0 -98
- data/lib/woods/formatting/generic_adapter.rb +0 -56
- data/lib/woods/formatting/gpt_adapter.rb +0 -64
- data/lib/woods/notion/mapper.rb +0 -40
- data/lib/woods/observability/health_check.rb +0 -79
- 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
|
|
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
|
-
#
|
|
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[
|
|
98
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
#
|
|
135
|
-
|
|
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
|
-
|
|
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,
|
|
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
|