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
@@ -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
- EvaluationReport = Struct.new(:results, :aggregates, keyword_init: true)
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
- def initialize(retriever:, query_set:, budget: 8000)
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 and aggregate metrics
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, expected),
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 (:lookup, :trace, :explain, :compare)
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 (:specific, :bounded, :broad)
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
- Query = Struct.new(:query, :expected_units, :intent, :scope, :tags, keyword_init: true)
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
- VALID_INTENTS = %i[lookup trace explain compare].freeze
32
- VALID_SCOPES = %i[specific bounded broad].freeze
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
- data = JSON.parse(File.read(path))
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
- File.write(path, JSON.pretty_generate(data))
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', 'lookup').to_sym,
112
- scope: hash.fetch('scope', 'specific').to_sym,
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
- return if VALID_SCOPES.include?(query.scope)
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, "Invalid scope: #{query.scope}. Must be one of #{VALID_SCOPES.join(', ')}"
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
@@ -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? ? (metadata.to_json.length / 4.0).ceil : 0
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