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
|
@@ -20,31 +20,57 @@ module Woods
|
|
|
20
20
|
QueryResult = Struct.new(:query, :expected_units, :retrieved_units, :scores, :tokens_used,
|
|
21
21
|
keyword_init: true)
|
|
22
22
|
|
|
23
|
-
# Aggregate report across all queries.
|
|
24
|
-
|
|
23
|
+
# Aggregate report across all queries. +threshold_report+ is nil unless
|
|
24
|
+
# thresholds were given — absent thresholds stay report-only.
|
|
25
|
+
EvaluationReport = Struct.new(:results, :aggregates, :threshold_report, keyword_init: true)
|
|
26
|
+
|
|
27
|
+
# Structured pass/fail against a thresholds hash. +metrics+ maps each
|
|
28
|
+
# thresholded aggregate key to { threshold:, actual:, delta:, passed: }.
|
|
29
|
+
ThresholdReport = Struct.new(:thresholds, :metrics, :passed, keyword_init: true)
|
|
25
30
|
|
|
26
31
|
METRIC_KEYS = %i[precision_at5 precision_at10 recall mrr context_completeness token_efficiency].freeze
|
|
27
32
|
|
|
28
33
|
# @param retriever [Woods::Retriever] Configured retriever instance
|
|
29
34
|
# @param query_set [QuerySet] Set of evaluation queries with ground truth
|
|
30
35
|
# @param budget [Integer] Token budget per query
|
|
31
|
-
|
|
36
|
+
# @param thresholds [Hash{Symbol=>Numeric}, nil] minimum value per
|
|
37
|
+
# aggregate key (e.g. `mean_recall: 0.7`). nil/empty means report-only:
|
|
38
|
+
# metrics are still computed but nothing is scored pass/fail.
|
|
39
|
+
def initialize(retriever:, query_set:, budget: 8000, thresholds: nil)
|
|
32
40
|
@retriever = retriever
|
|
33
41
|
@query_set = query_set
|
|
34
42
|
@budget = budget
|
|
43
|
+
@thresholds = thresholds
|
|
35
44
|
end
|
|
36
45
|
|
|
37
46
|
# Run all queries and produce an evaluation report.
|
|
38
47
|
#
|
|
39
|
-
# @return [EvaluationReport] Per-query results
|
|
48
|
+
# @return [EvaluationReport] Per-query results, aggregate metrics, and
|
|
49
|
+
# (when thresholds were given) a structured pass/fail
|
|
40
50
|
def evaluate
|
|
41
51
|
results = @query_set.queries.map { |q| evaluate_query(q) }
|
|
42
52
|
aggregates = compute_aggregates(results)
|
|
43
|
-
EvaluationReport.new(results: results, aggregates: aggregates
|
|
53
|
+
EvaluationReport.new(results: results, aggregates: aggregates,
|
|
54
|
+
threshold_report: evaluate_thresholds(aggregates))
|
|
44
55
|
end
|
|
45
56
|
|
|
46
57
|
private
|
|
47
58
|
|
|
59
|
+
# @param aggregates [Hash] computed aggregate metrics
|
|
60
|
+
# @return [ThresholdReport, nil] nil when no thresholds were configured
|
|
61
|
+
def evaluate_thresholds(aggregates)
|
|
62
|
+
return nil if @thresholds.nil? || @thresholds.empty?
|
|
63
|
+
|
|
64
|
+
metrics = @thresholds.to_h do |key, minimum|
|
|
65
|
+
actual = aggregates[key]
|
|
66
|
+
passed = !actual.nil? && actual >= minimum
|
|
67
|
+
delta = actual.nil? ? nil : (actual - minimum).round(6)
|
|
68
|
+
[key, { threshold: minimum, actual: actual, delta: delta, passed: passed }]
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
ThresholdReport.new(thresholds: @thresholds, metrics: metrics, passed: metrics.values.all? { |m| m[:passed] })
|
|
72
|
+
end
|
|
73
|
+
|
|
48
74
|
# Evaluate a single query against the retriever.
|
|
49
75
|
#
|
|
50
76
|
# @param query [QuerySet::Query] Evaluation query
|
|
@@ -53,7 +79,8 @@ module Woods
|
|
|
53
79
|
retrieval_result = @retriever.retrieve(query.query, budget: @budget)
|
|
54
80
|
retrieved_ids = extract_identifiers(retrieval_result)
|
|
55
81
|
|
|
56
|
-
scores = compute_scores(retrieved_ids, query.expected_units, retrieval_result
|
|
82
|
+
scores = compute_scores(retrieved_ids, query.expected_units, retrieval_result,
|
|
83
|
+
required: query.completeness_units)
|
|
57
84
|
|
|
58
85
|
QueryResult.new(
|
|
59
86
|
query: query.query,
|
|
@@ -76,17 +103,23 @@ module Woods
|
|
|
76
103
|
|
|
77
104
|
# Compute all metrics for a query result.
|
|
78
105
|
#
|
|
106
|
+
# +required+ is what separates `context_completeness` from `recall`
|
|
107
|
+
# (EXP-10): passing `expected` for both made the two metrics one number
|
|
108
|
+
# under two names. A query with no `required_units` annotation still
|
|
109
|
+
# passes its expected set, keeping the historical value.
|
|
110
|
+
#
|
|
79
111
|
# @param retrieved [Array<String>] Retrieved identifiers
|
|
80
112
|
# @param expected [Array<String>] Expected identifiers
|
|
81
113
|
# @param result [Retriever::RetrievalResult] Retrieval result
|
|
114
|
+
# @param required [Array<String>] Identifiers the retrieval must surface
|
|
82
115
|
# @return [Hash] Metric scores
|
|
83
|
-
def compute_scores(retrieved, expected, result)
|
|
116
|
+
def compute_scores(retrieved, expected, result, required: expected)
|
|
84
117
|
{
|
|
85
118
|
precision_at5: Metrics.precision_at_k(retrieved, expected, cutoff: 5),
|
|
86
119
|
precision_at10: Metrics.precision_at_k(retrieved, expected, cutoff: 10),
|
|
87
120
|
recall: Metrics.recall(retrieved, expected),
|
|
88
121
|
mrr: Metrics.mrr(retrieved, expected),
|
|
89
|
-
context_completeness: Metrics.context_completeness(retrieved,
|
|
122
|
+
context_completeness: Metrics.context_completeness(retrieved, required),
|
|
90
123
|
token_efficiency: compute_token_efficiency(retrieved, expected, result)
|
|
91
124
|
}
|
|
92
125
|
end
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'json'
|
|
4
|
+
require_relative '../retrieval/query_classifier'
|
|
5
|
+
|
|
3
6
|
module Woods
|
|
4
7
|
module Evaluation
|
|
5
8
|
# Manages a set of evaluation queries with expected results.
|
|
@@ -21,15 +24,50 @@ module Woods
|
|
|
21
24
|
# @!attribute [r] expected_units
|
|
22
25
|
# @return [Array<String>] Expected unit identifiers (ground truth)
|
|
23
26
|
# @!attribute [r] intent
|
|
24
|
-
# @return [Symbol] Query intent
|
|
27
|
+
# @return [Symbol] Query intent — one of {VALID_INTENTS}, the same
|
|
28
|
+
# vocabulary {Woods::Retrieval::QueryClassifier} produces
|
|
25
29
|
# @!attribute [r] scope
|
|
26
|
-
# @return [Symbol] Query scope
|
|
30
|
+
# @return [Symbol] Query scope — one of {VALID_SCOPES}, the same
|
|
31
|
+
# vocabulary {Woods::Retrieval::QueryClassifier} produces
|
|
27
32
|
# @!attribute [r] tags
|
|
28
33
|
# @return [Array<String>] Tags for filtering queries
|
|
29
|
-
|
|
34
|
+
# @!attribute [r] required_units
|
|
35
|
+
# @return [Array<String>, nil] The subset of {#expected_units} a
|
|
36
|
+
# retrieval *must* surface for the answer to be usable. Optional;
|
|
37
|
+
# absent (or empty) means "every expected unit is required", which
|
|
38
|
+
# is the semantics the harness had before the field existed.
|
|
39
|
+
Query = Struct.new(:query, :expected_units, :intent, :scope, :tags, :required_units, keyword_init: true) do
|
|
40
|
+
# The set +context_completeness+ is scored against.
|
|
41
|
+
#
|
|
42
|
+
# +context_completeness+ used to be handed {#expected_units}, making it
|
|
43
|
+
# recall under a second name (EXP-10). A query that annotates
|
|
44
|
+
# +required_units+ separates the two; one that doesn't keeps the old
|
|
45
|
+
# meaning.
|
|
46
|
+
#
|
|
47
|
+
# @return [Array<String>]
|
|
48
|
+
def completeness_units
|
|
49
|
+
required = required_units
|
|
50
|
+
required.nil? || required.empty? ? (expected_units || []) : required
|
|
51
|
+
end
|
|
52
|
+
end
|
|
30
53
|
|
|
31
|
-
|
|
32
|
-
|
|
54
|
+
# The annotation vocabulary is the *classifier's* vocabulary (#218 /
|
|
55
|
+
# B-105). These lists used to be a parallel invention — intents
|
|
56
|
+
# `lookup/trace/explain/compare` against the classifier's
|
|
57
|
+
# `understand/locate/trace/debug/implement/reference/compare/framework`,
|
|
58
|
+
# and scopes `specific/bounded/broad` against
|
|
59
|
+
# `pinpoint/focused/exploratory/comprehensive`, which share nothing at
|
|
60
|
+
# all. A ground-truth annotation could therefore never be compared with
|
|
61
|
+
# what the pipeline actually classified a query as, so the harness could
|
|
62
|
+
# measure retrieval hits but never classification accuracy.
|
|
63
|
+
VALID_INTENTS = Woods::Retrieval::QueryClassifier::INTENTS
|
|
64
|
+
VALID_SCOPES = Woods::Retrieval::QueryClassifier::SCOPES
|
|
65
|
+
|
|
66
|
+
# Old annotation values, accepted on load and translated. No shipped
|
|
67
|
+
# query set uses them (the harness had no working entry point until
|
|
68
|
+
# #212), but a hand-written file might.
|
|
69
|
+
LEGACY_INTENTS = { lookup: :locate, explain: :understand }.freeze
|
|
70
|
+
LEGACY_SCOPES = { specific: :pinpoint, bounded: :focused, broad: :exploratory }.freeze
|
|
33
71
|
|
|
34
72
|
# @return [Array<Query>] The queries in this set
|
|
35
73
|
attr_reader :queries
|
|
@@ -47,7 +85,12 @@ module Woods
|
|
|
47
85
|
# @return [QuerySet] Loaded query set
|
|
48
86
|
# @raise [Woods::Error] if the file cannot be read or parsed
|
|
49
87
|
def self.load(path)
|
|
50
|
-
|
|
88
|
+
# Explicit UTF-8: a query set is UTF-8 on disk, and a bare read tags
|
|
89
|
+
# it with the process default external encoding — under a POSIX locale
|
|
90
|
+
# the first accented query raised a raw Encoding error out of
|
|
91
|
+
# JSON.parse, past both typed branches below (EXP-7). Same pattern as
|
|
92
|
+
# {Woods::Evaluation::Baseline.load}.
|
|
93
|
+
data = JSON.parse(File.read(path, encoding: 'UTF-8'))
|
|
51
94
|
queries = data.fetch('queries', []).map { |q| parse_query(q) }
|
|
52
95
|
new(queries: queries)
|
|
53
96
|
rescue JSON::ParserError => e
|
|
@@ -64,7 +107,9 @@ module Woods
|
|
|
64
107
|
data = {
|
|
65
108
|
'queries' => queries.map { |q| serialize_query(q) }
|
|
66
109
|
}
|
|
67
|
-
|
|
110
|
+
# Pair the explicit UTF-8 with {.load} so a saved set reads back
|
|
111
|
+
# byte-for-byte regardless of the writer's locale (EXP-7).
|
|
112
|
+
File.write(path, JSON.pretty_generate(data), encoding: 'UTF-8')
|
|
68
113
|
end
|
|
69
114
|
|
|
70
115
|
# Filter queries by intent, scope, or tags.
|
|
@@ -108,9 +153,10 @@ module Woods
|
|
|
108
153
|
Query.new(
|
|
109
154
|
query: hash.fetch('query'),
|
|
110
155
|
expected_units: hash.fetch('expected_units', []),
|
|
111
|
-
intent: hash.fetch('intent', '
|
|
112
|
-
scope: hash.fetch('scope', '
|
|
113
|
-
tags: hash.fetch('tags', [])
|
|
156
|
+
intent: LEGACY_INTENTS.fetch(hash.fetch('intent', 'understand').to_sym) { |v| v },
|
|
157
|
+
scope: LEGACY_SCOPES.fetch(hash.fetch('scope', 'focused').to_sym) { |v| v },
|
|
158
|
+
tags: hash.fetch('tags', []),
|
|
159
|
+
required_units: hash['required_units']
|
|
114
160
|
)
|
|
115
161
|
end
|
|
116
162
|
|
|
@@ -118,16 +164,21 @@ module Woods
|
|
|
118
164
|
|
|
119
165
|
# Serialize a Query to a hash for JSON output.
|
|
120
166
|
#
|
|
167
|
+
# +required_units+ is omitted when the query does not annotate one, so a
|
|
168
|
+
# set that never used the field round-trips byte-identically.
|
|
169
|
+
#
|
|
121
170
|
# @param query [Query] Query to serialize
|
|
122
171
|
# @return [Hash]
|
|
123
172
|
def serialize_query(query)
|
|
124
|
-
{
|
|
173
|
+
data = {
|
|
125
174
|
'query' => query.query,
|
|
126
175
|
'expected_units' => query.expected_units,
|
|
127
176
|
'intent' => query.intent.to_s,
|
|
128
177
|
'scope' => query.scope.to_s,
|
|
129
178
|
'tags' => query.tags
|
|
130
179
|
}
|
|
180
|
+
data['required_units'] = query.required_units if query.required_units
|
|
181
|
+
data
|
|
131
182
|
end
|
|
132
183
|
|
|
133
184
|
# Validate intent and scope values.
|
|
@@ -139,9 +190,24 @@ module Woods
|
|
|
139
190
|
raise ArgumentError, "Invalid intent: #{query.intent}. Must be one of #{VALID_INTENTS.join(', ')}"
|
|
140
191
|
end
|
|
141
192
|
|
|
142
|
-
|
|
193
|
+
unless VALID_SCOPES.include?(query.scope)
|
|
194
|
+
raise ArgumentError, "Invalid scope: #{query.scope}. Must be one of #{VALID_SCOPES.join(', ')}"
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
validate_required_units!(query)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
# +required_units+ is documented as a subset of +expected_units+; a
|
|
201
|
+
# required unit outside the ground truth could never be scored as
|
|
202
|
+
# retrieved-and-relevant, so it is a typo, not a stricter bar.
|
|
203
|
+
#
|
|
204
|
+
# @param query [Query]
|
|
205
|
+
# @raise [ArgumentError] when a required unit is not expected
|
|
206
|
+
def validate_required_units!(query)
|
|
207
|
+
stray = Array(query.required_units) - Array(query.expected_units)
|
|
208
|
+
return if stray.empty?
|
|
143
209
|
|
|
144
|
-
raise ArgumentError, "
|
|
210
|
+
raise ArgumentError, "required_units must be a subset of expected_units; unknown: #{stray.join(', ')}"
|
|
145
211
|
end
|
|
146
212
|
end
|
|
147
213
|
end
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require 'json'
|
|
4
|
+
require 'time' # Time#iso8601 — the file is loadable standalone (EXP-8)
|
|
5
|
+
require 'fileutils' # FileUtils.mkdir_p in #save
|
|
4
6
|
|
|
5
7
|
module Woods
|
|
6
8
|
module Evaluation
|
|
@@ -44,11 +46,14 @@ module Woods
|
|
|
44
46
|
# @param metadata [Hash] Additional metadata
|
|
45
47
|
# @return [Hash]
|
|
46
48
|
def build_report_hash(report, metadata)
|
|
47
|
-
{
|
|
49
|
+
hash = {
|
|
48
50
|
'metadata' => build_metadata(metadata),
|
|
49
51
|
'aggregates' => serialize_aggregates(report.aggregates),
|
|
50
52
|
'results' => report.results.map { |r| serialize_result(r) }
|
|
51
53
|
}
|
|
54
|
+
threshold_report = report.threshold_report
|
|
55
|
+
hash['threshold_report'] = serialize_threshold_report(threshold_report) if threshold_report
|
|
56
|
+
hash
|
|
52
57
|
end
|
|
53
58
|
|
|
54
59
|
# Build the metadata section.
|
|
@@ -72,6 +77,20 @@ module Woods
|
|
|
72
77
|
end
|
|
73
78
|
end
|
|
74
79
|
|
|
80
|
+
# Serialize a threshold report (nil-safe values: a missing aggregate has
|
|
81
|
+
# a nil actual/delta, which JSON renders as null rather than raising).
|
|
82
|
+
#
|
|
83
|
+
# @param threshold_report [Evaluator::ThresholdReport]
|
|
84
|
+
# @return [Hash] String-keyed hash
|
|
85
|
+
def serialize_threshold_report(threshold_report)
|
|
86
|
+
{
|
|
87
|
+
'passed' => threshold_report.passed,
|
|
88
|
+
'metrics' => threshold_report.metrics.transform_keys(&:to_s).transform_values do |m|
|
|
89
|
+
m.transform_keys(&:to_s)
|
|
90
|
+
end
|
|
91
|
+
}
|
|
92
|
+
end
|
|
93
|
+
|
|
75
94
|
# Serialize a single query result.
|
|
76
95
|
#
|
|
77
96
|
# @param result [Evaluator::QueryResult] Query result
|
|
@@ -59,17 +59,6 @@ module Woods
|
|
|
59
59
|
@meta['callbacks'].map { |cb| { type: cb['type'], filter: cb['filter'] } }
|
|
60
60
|
end
|
|
61
61
|
|
|
62
|
-
# Blast-radius bucket for a dependent count.
|
|
63
|
-
#
|
|
64
|
-
# @param dependent_count [Integer]
|
|
65
|
-
# @return [Symbol] :high (>50), :moderate (>20), or :none
|
|
66
|
-
def self.blast_radius(dependent_count)
|
|
67
|
-
return :high if dependent_count > 50
|
|
68
|
-
return :moderate if dependent_count > 20
|
|
69
|
-
|
|
70
|
-
:none
|
|
71
|
-
end
|
|
72
|
-
|
|
73
62
|
private
|
|
74
63
|
|
|
75
64
|
def associations
|
data/lib/woods/extracted_unit.rb
CHANGED
|
@@ -69,10 +69,31 @@ module Woods
|
|
|
69
69
|
# @return [Integer] Estimated token count
|
|
70
70
|
def estimated_tokens
|
|
71
71
|
source_tokens = source_code ? (source_code.length / 4.0).ceil : 0
|
|
72
|
-
metadata_tokens = metadata.any? ? (
|
|
72
|
+
metadata_tokens = metadata.any? ? (serialized_metadata.length / 4.0).ceil : 0
|
|
73
73
|
source_tokens + metadata_tokens
|
|
74
74
|
end
|
|
75
75
|
|
|
76
|
+
# Metadata serialized the way the index writer serializes it.
|
|
77
|
+
#
|
|
78
|
+
# `Hash#to_json` and `JSON.generate` disagree about values JSON has no
|
|
79
|
+
# native representation for. With ActiveSupport loaded, `to_json` routes
|
|
80
|
+
# through `as_json`, which renders a Class as its (empty) instance values
|
|
81
|
+
# — `{}`. `JSON.generate`, which `Extractor#json_serialize` uses to write
|
|
82
|
+
# the unit file, falls back to `to_s` — `"ActionDispatch::Session::CookieStore"`.
|
|
83
|
+
#
|
|
84
|
+
# Estimating against `to_json` therefore described a document that was
|
|
85
|
+
# never written: on Rails < 7.1, `BehavioralProfile` carries
|
|
86
|
+
# `config.session_store` as a Class, and the estimate came out ~9 tokens
|
|
87
|
+
# short of the file on disk. Since an incremental run recomputes the
|
|
88
|
+
# estimate by reading that file back, the two paths disagreed (#164).
|
|
89
|
+
#
|
|
90
|
+
# @return [String]
|
|
91
|
+
def serialized_metadata
|
|
92
|
+
JSON.generate(metadata)
|
|
93
|
+
rescue StandardError
|
|
94
|
+
metadata.to_json
|
|
95
|
+
end
|
|
96
|
+
|
|
76
97
|
# Check if unit needs chunking based on size
|
|
77
98
|
#
|
|
78
99
|
# @param threshold [Integer] Token threshold for chunking (default: 1500)
|
|
@@ -80,67 +101,5 @@ module Woods
|
|
|
80
101
|
def needs_chunking?(threshold: 1500)
|
|
81
102
|
estimated_tokens > threshold
|
|
82
103
|
end
|
|
83
|
-
|
|
84
|
-
# Build semantic chunks for large units
|
|
85
|
-
# Preserves context by including unit header in each chunk
|
|
86
|
-
#
|
|
87
|
-
# @param max_tokens [Integer] Maximum tokens per chunk
|
|
88
|
-
# @return [Array<Hash>] List of chunk hashes
|
|
89
|
-
def build_default_chunks(max_tokens: 1500)
|
|
90
|
-
return [] unless needs_chunking?
|
|
91
|
-
|
|
92
|
-
chunks = []
|
|
93
|
-
current_chunk = []
|
|
94
|
-
current_tokens = 0
|
|
95
|
-
|
|
96
|
-
# Always include a header with unit context
|
|
97
|
-
header = build_chunk_header
|
|
98
|
-
header_tokens = (header.length / 4.0).ceil
|
|
99
|
-
|
|
100
|
-
source_code.lines.each do |line|
|
|
101
|
-
line_tokens = (line.length / 4.0).ceil
|
|
102
|
-
|
|
103
|
-
if current_tokens + line_tokens > max_tokens && current_chunk.any?
|
|
104
|
-
content = header + current_chunk.join
|
|
105
|
-
chunks << {
|
|
106
|
-
chunk_index: chunks.size,
|
|
107
|
-
identifier: "#{identifier}#chunk_#{chunks.size}",
|
|
108
|
-
content: content,
|
|
109
|
-
content_hash: Digest::SHA256.hexdigest(content),
|
|
110
|
-
estimated_tokens: current_tokens + header_tokens
|
|
111
|
-
}
|
|
112
|
-
current_chunk = []
|
|
113
|
-
current_tokens = 0
|
|
114
|
-
end
|
|
115
|
-
|
|
116
|
-
current_chunk << line
|
|
117
|
-
current_tokens += line_tokens
|
|
118
|
-
end
|
|
119
|
-
|
|
120
|
-
# Final chunk
|
|
121
|
-
if current_chunk.any?
|
|
122
|
-
content = header + current_chunk.join
|
|
123
|
-
chunks << {
|
|
124
|
-
chunk_index: chunks.size,
|
|
125
|
-
identifier: "#{identifier}#chunk_#{chunks.size}",
|
|
126
|
-
content: content,
|
|
127
|
-
content_hash: Digest::SHA256.hexdigest(content),
|
|
128
|
-
estimated_tokens: current_tokens + header_tokens
|
|
129
|
-
}
|
|
130
|
-
end
|
|
131
|
-
|
|
132
|
-
chunks
|
|
133
|
-
end
|
|
134
|
-
|
|
135
|
-
private
|
|
136
|
-
|
|
137
|
-
def build_chunk_header
|
|
138
|
-
<<~HEADER
|
|
139
|
-
# Unit: #{identifier} (#{type})
|
|
140
|
-
# File: #{file_path}
|
|
141
|
-
# Namespace: #{namespace || '(root)'}
|
|
142
|
-
# ---
|
|
143
|
-
HEADER
|
|
144
|
-
end
|
|
145
104
|
end
|
|
146
105
|
end
|