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
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'shellwords'
4
+
5
+ require_relative 'ablation_worktree'
6
+ require_relative 'ablation_summary'
7
+ require_relative 'ablation_provenance'
8
+ require_relative 'ablation_agent_payload'
9
+ require_relative 'ablation_executor'
10
+ require_relative 'ablation_timed_executor'
11
+
12
+ module Woods
13
+ module Evaluation
14
+ # Runs every task in an {AblationTaskSet} twice, index on and index off,
15
+ # each trial in its own disposable {AblationWorktree} checked out from a
16
+ # fixed baseline commit, and reports resolution rate, tokens, cost,
17
+ # turns, and errors per condition (#280).
18
+ #
19
+ # This is a harness for collecting paired on/off runs, not a source of
20
+ # causal evidence: sample size, task selection, and agent nondeterminism
21
+ # all bear on what a result means. See docs/EVALUATION.md.
22
+ #
23
+ # The agent is any command that prints one JSON object on stdout in the
24
+ # `claude -p --output-format json` shape. The executor is injectable so
25
+ # the runner never invokes a real agent in specs.
26
+ #
27
+ # Every command a trial runs (worktree add/remove, the optional reset,
28
+ # the agent invocation, and the check) shares one {AblationTimedExecutor}
29
+ # and is therefore bound by the same `timeout`, applied per call rather
30
+ # than once for the whole trial: a slow reset does not eat into the
31
+ # agent's budget, and vice versa. A timed-out call is terminated (TERM,
32
+ # then KILL if still alive after a short grace period) when the
33
+ # underlying executor exposes a pid.
34
+ #
35
+ # @example
36
+ # set = AblationTaskSet.load('config/eval_ablation.json')
37
+ # report = AblationRunner.new(task_set: set, workdir: Rails.root.to_s).run
38
+ # report.summary[:delta][:mean_tokens] # => negative when the index saves tokens
39
+ #
40
+ class AblationRunner
41
+ Result = Struct.new(:task_id, :condition, :resolved, :total_tokens, :cost_usd, :turns, :duration_ms,
42
+ :error, :provenance, keyword_init: true)
43
+ Report = Struct.new(:results, :summary, keyword_init: true)
44
+
45
+ DEFAULT_TIMEOUT = 600
46
+
47
+ # Verifies Woods availability before a trial runs, distinguishing "MCP
48
+ # enabled" (the agent command is wired to reach the Woods MCP server)
49
+ # from "index present" (an index actually exists in the checkout): the
50
+ # `:on` condition needs both. The `:off` condition needs both signals
51
+ # too, in the negative: `--strict-mcp-config` declared, and no
52
+ # `--mcp-config` reference at all. A command carrying both flags is
53
+ # still wired to Woods and fails preflight.
54
+ DEFAULT_WOODS_PROBE = lambda do |condition, chdir, command|
55
+ mcp_wired = command.include?('--mcp-config')
56
+ strict = command.include?('--strict-mcp-config')
57
+ index_present = File.exist?(File.join(chdir, 'tmp', 'woods', 'generation.json'))
58
+ if condition == :on
59
+ mcp_wired && index_present
60
+ else
61
+ strict && !mcp_wired
62
+ end
63
+ end
64
+
65
+ # @param task_set [AblationTaskSet::Definition]
66
+ # @param workdir [String] the host application root
67
+ # @param executor [#call] `call(command, chdir:)` returning `[stdout, stderr, success]`.
68
+ # Wrapped in an {AblationTimedExecutor} exactly once, so every command a trial runs
69
+ # through it (worktree add/remove, reset, agent, check) shares the same per-call
70
+ # timeout; see `timeout` below.
71
+ # @param conditions [Array<Symbol>] subset of `%i[on off]`
72
+ # @param trial_options [Hash]
73
+ # @option trial_options [Numeric] :timeout seconds allowed per call the trial makes
74
+ # (worktree add/remove, the optional reset, the agent invocation, and the check),
75
+ # applied independently to each one rather than shared across the whole trial
76
+ # @option trial_options [#call] :woods_probe `call(condition, chdir, command)` returning a boolean
77
+ def initialize(task_set:, workdir:, executor: nil, conditions: %i[on off], **trial_options)
78
+ @task_set = task_set
79
+ @workdir = workdir
80
+ @conditions = conditions
81
+ @timeout = trial_options.fetch(:timeout, DEFAULT_TIMEOUT)
82
+ @woods_probe = trial_options.fetch(:woods_probe, DEFAULT_WOODS_PROBE)
83
+ @executor = AblationTimedExecutor.new(executor || AblationExecutor.new, timeout: @timeout)
84
+ end
85
+
86
+ # @return [Report]
87
+ def run
88
+ baseline_sha = resolve_baseline_sha
89
+ results = @task_set.tasks.flat_map do |task|
90
+ @conditions.map { |condition| run_trial(task, condition, baseline_sha) }
91
+ end
92
+ Report.new(results: results, summary: AblationSummary.build(results, @conditions))
93
+ end
94
+
95
+ private
96
+
97
+ def resolve_baseline_sha
98
+ out, _err, ok = @executor.call('git rev-parse HEAD', chdir: @workdir)
99
+ ok ? out.strip : nil
100
+ end
101
+
102
+ def run_trial(task, condition, baseline_sha)
103
+ worktree = AblationWorktree.new(repo_root: @workdir, baseline_sha: baseline_sha, executor: @executor)
104
+ result = nil
105
+ setup_error = worktree.trial(@task_set.reset) { |path| result = execute(task, condition, path, baseline_sha) }
106
+ result || failed_result(task, condition, blank_provenance(condition, baseline_sha), setup_error)
107
+ end
108
+
109
+ def execute(task, condition, path, baseline_sha)
110
+ chdir = File.expand_path(task.workdir, path)
111
+ command = agent_command(condition).sub('{prompt}', Shellwords.escape(task.prompt))
112
+ provenance = AblationProvenance.build(agent_command: command, chdir: chdir, baseline_sha: baseline_sha)
113
+
114
+ probe_error = preflight_error(condition, chdir, command)
115
+ return failed_result(task, condition, provenance, probe_error) if probe_error
116
+
117
+ run_agent_and_check(task, condition, chdir, command, provenance)
118
+ end
119
+
120
+ def run_agent_and_check(task, condition, chdir, command, provenance)
121
+ stdout, stderr, success = @executor.call(command, chdir: chdir)
122
+ payload = AblationAgentPayload.parse(stdout)
123
+ provenance.model = payload && payload['model']
124
+ _check_stdout, check_stderr, resolved = @executor.call(task.check, chdir: chdir)
125
+
126
+ Result.new(task_id: task.id, condition: condition, resolved: resolved == true,
127
+ total_tokens: payload && AblationAgentPayload.token_total(payload['usage']),
128
+ cost_usd: payload && payload['total_cost_usd'],
129
+ turns: payload && payload['num_turns'],
130
+ duration_ms: payload && payload['duration_ms'],
131
+ error: agent_error(success, payload, stderr) || check_timeout_error(check_stderr),
132
+ provenance: provenance)
133
+ end
134
+
135
+ def agent_error(success, payload, stderr)
136
+ return nil if success && payload
137
+
138
+ [stderr.to_s.strip, payload ? nil : 'agent printed no JSON'].compact.join('; ')
139
+ end
140
+
141
+ # The check's own exit status already drives `resolved`; a normal test
142
+ # failure is not a harness error. A timeout is: {AblationTimedExecutor}
143
+ # marks it with a recognizable stderr message, so it still surfaces as
144
+ # an error (and is counted in the summary) even though `resolved` is
145
+ # also false.
146
+ def check_timeout_error(check_stderr)
147
+ return nil unless check_stderr.to_s.include?('timed out after')
148
+
149
+ "check #{check_stderr.strip}"
150
+ end
151
+
152
+ def preflight_error(condition, chdir, command)
153
+ return nil if @woods_probe.call(condition, chdir, command)
154
+
155
+ "Woods availability check failed for condition #{condition}"
156
+ end
157
+
158
+ def failed_result(task, condition, provenance, error)
159
+ Result.new(task_id: task.id, condition: condition, resolved: false, total_tokens: nil, cost_usd: nil,
160
+ turns: nil, duration_ms: nil, error: error, provenance: provenance)
161
+ end
162
+
163
+ def blank_provenance(condition, baseline_sha)
164
+ AblationProvenance.new(agent_command: agent_command(condition), model: nil, config: nil,
165
+ woods_generation: nil, baseline_sha: baseline_sha)
166
+ end
167
+
168
+ def agent_command(condition)
169
+ condition == :on ? @task_set.agent_on : @task_set.agent_off
170
+ end
171
+ end
172
+ end
173
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Woods
4
+ module Evaluation
5
+ # Aggregates {AblationRunner::Result} values into a per-condition summary
6
+ # (resolution rate, mean tokens/cost/turns, task and error counts) plus
7
+ # the on-minus-off delta (#280).
8
+ class AblationSummary
9
+ DELTA_METRICS = %i[resolution_rate mean_tokens mean_cost_usd mean_turns].freeze
10
+
11
+ # @param results [Array<AblationRunner::Result>]
12
+ # @param conditions [Array<Symbol>] subset of `%i[on off]`
13
+ # @return [Hash{Symbol=>Hash}]
14
+ def self.build(results, conditions)
15
+ new(results, conditions).build
16
+ end
17
+
18
+ def initialize(results, conditions)
19
+ @results = results
20
+ @conditions = conditions
21
+ end
22
+
23
+ def build
24
+ summary = {}
25
+ @conditions.each { |condition| summary[condition] = condition_summary(condition) }
26
+ summary[:delta] = delta(summary[:on], summary[:off]) if summary.key?(:on) && summary.key?(:off)
27
+ summary
28
+ end
29
+
30
+ private
31
+
32
+ def condition_summary(condition)
33
+ results = @results.select { |result| result.condition == condition }
34
+ {
35
+ resolution_rate: resolution_rate(results),
36
+ mean_tokens: mean(results.map(&:total_tokens)),
37
+ mean_cost_usd: mean(results.map(&:cost_usd)),
38
+ mean_turns: mean(results.map(&:turns)),
39
+ tasks: results.size,
40
+ errors: results.count(&:error)
41
+ }
42
+ end
43
+
44
+ def resolution_rate(results)
45
+ return 0.0 if results.empty?
46
+
47
+ (results.count(&:resolved).to_f / results.size).round(4)
48
+ end
49
+
50
+ def delta(on, off)
51
+ DELTA_METRICS.to_h do |key|
52
+ value = on[key].nil? || off[key].nil? ? nil : (on[key] - off[key]).round(4)
53
+ [key, value]
54
+ end
55
+ end
56
+
57
+ def mean(values)
58
+ present = values.compact
59
+ return nil if present.empty?
60
+
61
+ (present.sum.to_f / present.size).round(4)
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'ablation_task_set'
4
+ require_relative 'ablation_runner'
5
+ require_relative 'ablation_report_writer'
6
+
7
+ module Woods
8
+ module Evaluation
9
+ # Entry point for `woods:evaluate:ablation`: loads a task set, applies
10
+ # environment overrides, runs the harness, and writes the report (#280).
11
+ # Kept out of the rake file so the task definition stays a thin
12
+ # delegation and the logic is testable without Rake.
13
+ #
14
+ # File map for `lib/woods/evaluation/ablation_*.rb`: {AblationTaskSet}
15
+ # loads and validates the task set file; {AblationRunner} orchestrates a
16
+ # trial per task per condition; {AblationWorktree} gives each trial its
17
+ # own disposable git checkout; {AblationExecutor} is the default
18
+ # subprocess executor; {AblationTimedExecutor} wraps any executor with
19
+ # the per-call timeout and kill-on-timeout behavior; {AblationProvenance}
20
+ # and {AblationAgentPayload} build a trial's provenance and parse the
21
+ # agent's JSON payload; {AblationSummary} aggregates results into the
22
+ # per-condition and delta summary; {AblationReportWriter} serializes the
23
+ # report and renders the terminal summary; this class wires them
24
+ # together for the rake task.
25
+ class AblationTask
26
+ DEFAULT_TASKS = 'config/eval_ablation.json'
27
+ DEFAULT_OUTPUT = 'tmp/eval_ablation.json'
28
+
29
+ # @param task_set_path [String, nil]
30
+ # @param workdir [String]
31
+ # @return [AblationRunner::Report]
32
+ def self.run(task_set_path: nil, workdir: Dir.pwd)
33
+ new(task_set_path: task_set_path, workdir: workdir).run
34
+ end
35
+
36
+ def initialize(task_set_path: nil, workdir: Dir.pwd)
37
+ @task_set_path = task_set_path || ENV.fetch('EVAL_ABLATION_TASKS', DEFAULT_TASKS)
38
+ @output = ENV.fetch('EVAL_ABLATION_OUTPUT', DEFAULT_OUTPUT)
39
+ @workdir = workdir
40
+ end
41
+
42
+ # @return [AblationRunner::Report]
43
+ def run
44
+ task_set = load_task_set
45
+ puts "Running #{task_set.tasks.size} task(s) with the index on and off, " \
46
+ 'one disposable worktree per trial...'
47
+ report = AblationRunner.new(task_set: task_set, workdir: @workdir).run
48
+
49
+ writer = AblationReportWriter.new(report)
50
+ writer.write(@output)
51
+ puts writer.summary_text
52
+ puts "Report saved to: #{@output}"
53
+ report
54
+ end
55
+
56
+ private
57
+
58
+ def load_task_set
59
+ task_set = AblationTaskSet.load(@task_set_path)
60
+ task_set.agent_on = ENV['EVAL_AGENT_ON'] if ENV['EVAL_AGENT_ON']
61
+ task_set.agent_off = ENV['EVAL_AGENT_OFF'] if ENV['EVAL_AGENT_OFF']
62
+ task_set
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Woods
6
+ module Evaluation
7
+ # A task set for the agent-level ablation (#280): the same coding tasks
8
+ # run with the Woods index on and off.
9
+ #
10
+ # File shape (schema_version 1):
11
+ #
12
+ # {
13
+ # "schema_version": 1,
14
+ # "agent_on": "claude -p {prompt} --output-format json --mcp-config .mcp.json",
15
+ # "agent_off": "claude -p {prompt} --output-format json --strict-mcp-config",
16
+ # "reset": "git checkout -- . && git clean -fdq", // optional
17
+ # "tasks": [
18
+ # { "id": "...", "prompt": "...", "check": "bin/rspec spec/...", "workdir": "." }
19
+ # ]
20
+ # }
21
+ #
22
+ # `{prompt}` is replaced with the shell-escaped prompt. `check` is any
23
+ # command whose exit status says whether the task was resolved.
24
+ class AblationTaskSet
25
+ SUPPORTED_SCHEMA_VERSIONS = [1].freeze
26
+
27
+ Task = Struct.new(:id, :prompt, :check, :workdir, keyword_init: true)
28
+
29
+ # Named `Definition` rather than `Data`: Ruby 3.2 introduces a
30
+ # top-level `::Data` class, and shadowing it inside this class body is
31
+ # legal on the 3.0 floor but confusing to read.
32
+ Definition = Struct.new(:schema_version, :agent_on, :agent_off, :reset, :tasks, keyword_init: true)
33
+
34
+ class << self
35
+ # @param path [String]
36
+ # @return [Definition]
37
+ # @raise [Woods::Error] on an unreadable file, invalid JSON, or an invalid shape
38
+ def load(path)
39
+ raw = JSON.parse(File.read(path.to_s, encoding: 'UTF-8'))
40
+ version = raw['schema_version']
41
+ unless SUPPORTED_SCHEMA_VERSIONS.include?(version)
42
+ raise Woods::Error, "Unsupported ablation schema_version #{version.inspect} in #{path}"
43
+ end
44
+
45
+ Definition.new(
46
+ schema_version: version,
47
+ agent_on: command!(raw, 'agent_on', path),
48
+ agent_off: command!(raw, 'agent_off', path),
49
+ reset: raw['reset'],
50
+ tasks: Array(raw['tasks']).map { |task| build_task(task, path) }
51
+ )
52
+ rescue JSON::ParserError => e
53
+ raise Woods::Error, "Invalid JSON in ablation task set #{path}: #{e.message}"
54
+ rescue Errno::ENOENT => e
55
+ raise Woods::Error, "Ablation task set not found: #{e.message}"
56
+ end
57
+
58
+ private
59
+
60
+ def command!(raw, key, path)
61
+ value = raw[key].to_s
62
+ raise Woods::Error, "#{key} in #{path} must contain {prompt}" unless value.include?('{prompt}')
63
+
64
+ value
65
+ end
66
+
67
+ def build_task(task, path)
68
+ %w[id prompt check].each do |key|
69
+ raise Woods::Error, "Task #{task['id'].inspect} in #{path} is missing #{key}" if task[key].to_s.empty?
70
+ end
71
+
72
+ Task.new(id: task['id'], prompt: task['prompt'], check: task['check'], workdir: task['workdir'] || '.')
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'timeout'
4
+
5
+ module Woods
6
+ module Evaluation
7
+ # Bounds every call an ablation trial makes to the same per-call timeout
8
+ # (#280 review): a per-command budget applied independently to each
9
+ # command a trial runs (worktree add/remove, the optional reset, the
10
+ # agent invocation, the check), not one budget shared across the whole
11
+ # trial. `AblationRunner` wraps whatever executor it is given with this
12
+ # class exactly once, so `AblationWorktree` and the runner itself both
13
+ # get the bound for free by calling `@executor.call` as normal.
14
+ #
15
+ # On timeout, `Timeout.timeout` only interrupts the calling thread; a
16
+ # subprocess started by the wrapped executor keeps running unless
17
+ # something kills it. When the wrapped executor exposes its most recent
18
+ # pid (see {AblationExecutor}), this sends TERM, waits briefly, then KILL
19
+ # if the process is still alive, before reporting the call as a timed-out
20
+ # failure. An executor that does not expose a pid (for example a fake
21
+ # executor in a spec) still gets the timeout, just without a process to
22
+ # terminate.
23
+ class AblationTimedExecutor
24
+ TERM_GRACE_SECONDS = 2
25
+ REAP_GRACE_SECONDS = 2
26
+ POLL_INTERVAL = 0.05
27
+
28
+ # @param executor [#call] `call(command, chdir:)` returning `[stdout, stderr, success]`
29
+ # @param timeout [Numeric] seconds allowed per call
30
+ def initialize(executor, timeout:)
31
+ @executor = executor
32
+ @timeout = timeout
33
+ end
34
+
35
+ # @return [Array(String, String, Boolean)] stdout, stderr, success
36
+ def call(command, chdir:)
37
+ Timeout.timeout(@timeout) { @executor.call(command, chdir: chdir) }
38
+ rescue Timeout::Error
39
+ ['', "timed out after #{@timeout}s#{terminate_process}", false]
40
+ end
41
+
42
+ private
43
+
44
+ # @return [String] empty, or a note that the pid outlived the reap
45
+ # grace period, appended to the timeout message
46
+ def terminate_process
47
+ pid = executor_pid
48
+ return '' unless pid
49
+
50
+ Process.kill('TERM', pid)
51
+ Process.kill('KILL', pid) unless process_exited?(pid, within: TERM_GRACE_SECONDS)
52
+ reap(pid, within: REAP_GRACE_SECONDS) ? '' : " (pid #{pid} did not reap within #{REAP_GRACE_SECONDS}s)"
53
+ rescue Errno::ESRCH, Errno::ECHILD
54
+ ''
55
+ end
56
+
57
+ def executor_pid
58
+ @executor.pid if @executor.respond_to?(:pid)
59
+ end
60
+
61
+ def process_exited?(pid, within:)
62
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + within
63
+ until Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
64
+ begin
65
+ Process.kill(0, pid)
66
+ rescue Errno::ESRCH
67
+ return true
68
+ end
69
+ sleep POLL_INTERVAL
70
+ end
71
+ false
72
+ end
73
+
74
+ # Polls a non-blocking wait instead of a plain Process.wait (#280
75
+ # review, minor): a pid some other bug left un-reapable must not hang
76
+ # the whole trial forever just because we tried to clean up after it.
77
+ def reap(pid, within:)
78
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + within
79
+ until Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
80
+ reaped_pid, = Process.wait2(pid, Process::WNOHANG)
81
+ return true if reaped_pid
82
+
83
+ sleep POLL_INTERVAL
84
+ end
85
+ false
86
+ rescue Errno::ECHILD
87
+ true
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tmpdir'
4
+ require 'fileutils'
5
+ require 'shellwords'
6
+
7
+ module Woods
8
+ module Evaluation
9
+ # Sets up and tears down one disposable git worktree per ablation trial
10
+ # (#280), checked out from a fixed baseline SHA so an on/off pair for
11
+ # the same task starts from the identical commit. The trial never runs
12
+ # in the caller's own working directory, and a reset failure aborts the
13
+ # trial rather than being ignored.
14
+ #
15
+ # @example
16
+ # worktree = AblationWorktree.new(repo_root: '/app', baseline_sha: sha, executor: executor)
17
+ # error = worktree.trial('git checkout -- . && git clean -fdq') { |path| run_agent(path) }
18
+ # error # => nil on success, an error string otherwise (the block never ran)
19
+ class AblationWorktree
20
+ # @param repo_root [String] the repository the trial is checked out from
21
+ # @param baseline_sha [String, nil] the commit every trial checks out
22
+ # @param executor [#call] `call(command, chdir:)` returning `[stdout, stderr, success]`
23
+ def initialize(repo_root:, baseline_sha:, executor:)
24
+ @repo_root = repo_root
25
+ @baseline_sha = baseline_sha
26
+ @executor = executor
27
+ end
28
+
29
+ # @param reset_command [String, nil]
30
+ # @yieldparam path [String] the checkout root, only when setup succeeded
31
+ # @return [String, nil] an error message when the checkout or reset
32
+ # failed (the block never ran), else nil
33
+ def trial(reset_command)
34
+ parent = nil
35
+ path = nil
36
+ added = false
37
+
38
+ parent = Dir.mktmpdir('woods-ablation-')
39
+ path = File.join(parent, 'checkout')
40
+ error = add_worktree(path)
41
+ added = error.nil?
42
+ error ||= reset(path, reset_command) if added && reset_command
43
+ yield path if error.nil?
44
+ error
45
+ ensure
46
+ teardown(path) if added
47
+ FileUtils.remove_entry(parent, true) if parent
48
+ end
49
+
50
+ private
51
+
52
+ def add_worktree(path)
53
+ _out, err, ok = run_in_repo("git worktree add --detach #{Shellwords.escape(path)} #{@baseline_sha}")
54
+ ok ? nil : "git worktree add failed: #{err.to_s.strip}"
55
+ end
56
+
57
+ def reset(path, reset_command)
58
+ _out, err, ok = @executor.call(reset_command, chdir: path)
59
+ ok ? nil : "reset failed: #{err.to_s.strip}"
60
+ end
61
+
62
+ def teardown(path)
63
+ run_in_repo("git worktree remove --force #{Shellwords.escape(path)}")
64
+ end
65
+
66
+ def run_in_repo(command)
67
+ @executor.call(command, chdir: @repo_root)
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Woods
6
+ module Evaluation
7
+ # Loads a versioned baseline/thresholds file for `woods:evaluate`.
8
+ #
9
+ # File shape (schema_version 1):
10
+ #
11
+ # {
12
+ # "schema_version": 1,
13
+ # "query_set": "config/eval_queries.json",
14
+ # "captured_at": "2026-08-20T00:00:00Z", // or null if never captured
15
+ # "thresholds": {
16
+ # "mean_precision_at5": 0.0,
17
+ # "mean_recall": 0.0,
18
+ # "mean_mrr": 0.0
19
+ # },
20
+ # "notes": "free text"
21
+ # }
22
+ #
23
+ # `thresholds` keys must match {Evaluator::EvaluationReport}'s +aggregates+
24
+ # keys (e.g. "mean_recall", not "recall") — the evaluator compares them
25
+ # directly against computed aggregates.
26
+ #
27
+ # The checked-in example under spec/fixtures/evaluation_baseline.example.json
28
+ # is a format fixture only: its thresholds are 0.0 placeholders, not a
29
+ # captured v2 corpus baseline. Capturing the real baseline — running the
30
+ # harness against the pinned v2 corpus and recording the resulting scores
31
+ # as thresholds — is future work.
32
+ class Baseline
33
+ SUPPORTED_SCHEMA_VERSIONS = [1].freeze
34
+
35
+ Data = Struct.new(:schema_version, :query_set, :captured_at, :thresholds, :notes, keyword_init: true)
36
+
37
+ class << self
38
+ # @param path [String] path to a baseline JSON file
39
+ # @return [Data]
40
+ # @raise [Woods::Error] on an unreadable file, invalid JSON, or an
41
+ # unsupported schema_version
42
+ def load(path)
43
+ raw = JSON.parse(File.read(path.to_s, encoding: 'UTF-8'))
44
+ version = raw['schema_version']
45
+ unless SUPPORTED_SCHEMA_VERSIONS.include?(version)
46
+ raise Woods::Error, "Unsupported baseline schema_version #{version.inspect} in #{path}"
47
+ end
48
+
49
+ Data.new(
50
+ schema_version: version,
51
+ query_set: raw['query_set'],
52
+ captured_at: raw['captured_at'],
53
+ thresholds: (raw['thresholds'] || {}).transform_keys(&:to_sym),
54
+ notes: raw['notes']
55
+ )
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative '../storage_identity'
4
+
3
5
  module Woods
4
6
  module Evaluation
5
7
  # Runs simple baseline strategies for comparison against the full
@@ -51,6 +53,12 @@ module Woods
51
53
 
52
54
  private
53
55
 
56
+ # Evaluation ground truth uses public names, not vector/metadata storage keys.
57
+ # Coexisting types count once because the evaluation contract is name-based.
58
+ def public_identifiers
59
+ @metadata_store.all_identifiers.map { |id| StorageIdentity.identifier(id) }.uniq
60
+ end
61
+
54
62
  # Grep strategy: substring match on unit identifiers.
55
63
  #
56
64
  # Extracts words from the query and matches identifiers that contain
@@ -60,7 +68,7 @@ module Woods
60
68
  # @param limit [Integer] Max results
61
69
  # @return [Array<String>]
62
70
  def run_grep(query, limit)
63
- all_ids = @metadata_store.all_identifiers
71
+ all_ids = public_identifiers
64
72
  keywords = extract_keywords(query)
65
73
 
66
74
  return all_ids.first(limit) if keywords.empty?
@@ -85,7 +93,7 @@ module Woods
85
93
  # @return [Array<String>]
86
94
  def run_random(_query, limit)
87
95
  @random_mutex.synchronize do
88
- @metadata_store.all_identifiers.sample(limit, random: @random)
96
+ public_identifiers.sample(limit, random: @random)
89
97
  end
90
98
  end
91
99
 
@@ -96,7 +104,7 @@ module Woods
96
104
  # @param limit [Integer] Max results
97
105
  # @return [Array<String>]
98
106
  def run_file_level(query, limit)
99
- all_ids = @metadata_store.all_identifiers
107
+ all_ids = public_identifiers
100
108
  keywords = extract_keywords(query)
101
109
 
102
110
  return all_ids.first(limit) if keywords.empty?