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
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'net/http'
4
4
  require 'json'
5
+ require_relative 'provider'
5
6
 
6
7
  module Woods
7
8
  module Embedding
@@ -17,6 +18,7 @@ module Woods
17
18
  # vectors = provider.embed_batch(["text1", "text2"])
18
19
  class OpenAI
19
20
  include Interface
21
+ include DiscardableClient
20
22
 
21
23
  ENDPOINT = URI('https://api.openai.com/v1/embeddings')
22
24
  DEFAULT_MODEL = 'text-embedding-3-small'
@@ -33,9 +35,11 @@ module Woods
33
35
 
34
36
  # @param api_key [String] OpenAI API key
35
37
  # @param model [String] OpenAI embedding model name (default: text-embedding-3-small)
36
- def initialize(api_key:, model: DEFAULT_MODEL)
38
+ # @param dimensions [Integer, nil] Requested output size for text-embedding-3 models
39
+ def initialize(api_key:, model: DEFAULT_MODEL, dimensions: nil)
37
40
  @api_key = api_key
38
41
  @model = model
42
+ @dimensions = normalize_dimensions(dimensions)
39
43
  end
40
44
 
41
45
  # Embed a single text string.
@@ -47,8 +51,10 @@ module Woods
47
51
  def embed(text)
48
52
  raise ArgumentError, 'embed(text) requires a non-empty string' if text.nil? || text.to_s.strip.empty?
49
53
 
50
- response = post_request({ model: @model, input: text })
51
- response['data'].first['embedding']
54
+ response = post_request(request_body(text))
55
+ vectors = Array(response['data']).map { |item| item['embedding'] }
56
+ VectorValidation.validate!(vectors, expected_count: 1, provider: 'OpenAI')
57
+ vectors.first
52
58
  end
53
59
 
54
60
  # Embed multiple texts in a single request.
@@ -59,16 +65,14 @@ module Woods
59
65
  # @return [Array<Array<Float>>] array of embedding vectors
60
66
  # @raise [Woods::Error] if the API returns an error
61
67
  # @raise [ArgumentError] if the array is empty or any element is nil/empty
62
- def embed_batch(texts) # rubocop:disable Metrics/CyclomaticComplexity
68
+ def embed_batch(texts)
63
69
  raise ArgumentError, 'embed_batch(texts) requires a non-empty array' if texts.nil? || texts.empty?
64
70
  if texts.any? { |t| t.nil? || t.to_s.strip.empty? }
65
71
  raise ArgumentError, 'embed_batch(texts) rejects nil/empty entries (OpenAI returns 400)'
66
72
  end
67
73
 
68
- response = post_request({ model: @model, input: texts })
69
- response['data']
70
- .sort_by { |item| item['index'] }
71
- .map { |item| item['embedding'] }
74
+ response = post_request(request_body(texts))
75
+ extract_validated_batch(response, texts.size)
72
76
  end
73
77
 
74
78
  # Return the dimensionality of vectors produced by this model.
@@ -78,7 +82,7 @@ module Woods
78
82
  #
79
83
  # @return [Integer] number of dimensions
80
84
  def dimensions
81
- DIMENSIONS[@model] || embed('test').length
85
+ @dimensions || DIMENSIONS[@model] || embed('test').length
82
86
  end
83
87
 
84
88
  # Return the model name.
@@ -98,6 +102,38 @@ module Woods
98
102
 
99
103
  private
100
104
 
105
+ # Validate the batch response's shape/cardinality/indexes and
106
+ # return vectors reordered to match the input order.
107
+ #
108
+ # @param response [Hash] parsed JSON response
109
+ # @param expected_count [Integer] number of texts requested
110
+ # @return [Array<Array<Float>>]
111
+ def extract_validated_batch(response, expected_count)
112
+ data = Array(response['data'])
113
+ VectorValidation.validate!(
114
+ data.map { |item| item['embedding'] },
115
+ expected_count: expected_count,
116
+ provider: 'OpenAI',
117
+ indexes: data.map { |item| item['index'] }
118
+ )
119
+ data.sort_by { |item| item['index'] }.map { |item| item['embedding'] }
120
+ end
121
+
122
+ def request_body(input)
123
+ { model: @model, input: input }.tap do |body|
124
+ body[:dimensions] = @dimensions if @dimensions
125
+ end
126
+ end
127
+
128
+ def normalize_dimensions(value)
129
+ return if value.nil?
130
+
131
+ dimensions = Integer(value)
132
+ raise ArgumentError, "dimensions must be positive, got #{value.inspect}" unless dimensions.positive?
133
+
134
+ dimensions
135
+ end
136
+
101
137
  # Cap interpolated response bodies so misconfigured API errors
102
138
  # (which occasionally echo request metadata, including headers) don't
103
139
  # unbounded-leak into logs or re-raised messages.
@@ -116,7 +152,7 @@ module Woods
116
152
  # @param body [Hash] request body
117
153
  # @return [Hash] parsed JSON response
118
154
  # @raise [Woods::Error] if the API returns a non-success status
119
- def post_request(body) # rubocop:disable Metrics/AbcSize
155
+ def post_request(body)
120
156
  request = Net::HTTP::Post.new(ENDPOINT.path)
121
157
  request['Content-Type'] = 'application/json'
122
158
  request['Authorization'] = "Bearer #{@api_key}"
@@ -124,22 +160,44 @@ module Woods
124
160
 
125
161
  response = http_client.request(request)
126
162
 
127
- unless response.is_a?(Net::HTTPSuccess)
128
- raise Woods::Error, "OpenAI API error: #{response.code} #{truncate_response_body(response.body)}"
129
- end
163
+ raise request_error(response) unless response.is_a?(Net::HTTPSuccess)
130
164
 
131
165
  JSON.parse(response.body)
132
- rescue Errno::ECONNRESET, Net::OpenTimeout, IOError
133
- # Connection dropped — reset and retry once
134
- @http_client = nil
135
- response = http_client.request(request)
136
- unless response.is_a?(Net::HTTPSuccess)
137
- raise Woods::Error, "OpenAI API error: #{response.code} #{truncate_response_body(response.body)}"
166
+ rescue Errno::ECONNRESET, Net::OpenTimeout, Net::ReadTimeout, IOError
167
+ # Connection dropped — reset and retry once. A second transport
168
+ # failure is wrapped (mirroring Ollama) rather than left to escape
169
+ # as a raw Errno, so callers rescuing Woods::Error see a typed,
170
+ # context-bearing failure.
171
+ #
172
+ # Net::ReadTimeout descends from Timeout::Error, not IOError, so it
173
+ # needs naming explicitly or a stalled response escapes both the
174
+ # retry and the typing (STO-15).
175
+ discard_http_client
176
+ begin
177
+ response = http_client.request(request)
178
+ rescue StandardError => retry_error
179
+ raise RequestError, "OpenAI API error (retry failed): #{retry_error.message}"
138
180
  end
181
+ raise request_error(response) unless response.is_a?(Net::HTTPSuccess)
139
182
 
140
183
  JSON.parse(response.body)
141
184
  end
142
185
 
186
+ # Build a {RequestError} from a non-success OpenAI response,
187
+ # attaching the HTTP status and any +Retry-After+ header so the
188
+ # resilience layer can classify the failure (429/5xx retryable,
189
+ # 400/401 not) and honor the server-requested back-off.
190
+ #
191
+ # @param response [Net::HTTPResponse]
192
+ # @return [RequestError]
193
+ def request_error(response)
194
+ RequestError.new(
195
+ "OpenAI API error: #{response.code} #{truncate_response_body(response.body)}",
196
+ http_status: response.code.to_i,
197
+ retry_after: response['Retry-After']
198
+ )
199
+ end
200
+
143
201
  # Return a reusable, started HTTP client for the OpenAI API.
144
202
  # Calling http.start opens a persistent TCP connection so
145
203
  # keep_alive_timeout actually takes effect across requests.
@@ -4,6 +4,11 @@ require 'net/http'
4
4
  require 'json'
5
5
 
6
6
  module Woods
7
+ # Standalone-load guard — keeps `require 'woods/embedding/provider'`
8
+ # working without the top-level woods.rb (same pattern as storage/
9
+ # and console/). {Provider::RequestError} subclasses this at load time.
10
+ class Error < StandardError; end unless defined?(Woods::Error)
11
+
7
12
  module Embedding
8
13
  # Interface and adapters for embedding providers.
9
14
  #
@@ -61,6 +66,151 @@ module Woods
61
66
  end
62
67
  end
63
68
 
69
+ # Shared connection hygiene for adapters that hold one persistent
70
+ # Net::HTTP client in +@http_client+. Closing a connection before
71
+ # dropping the reference keeps the descriptor from lingering until GC.
72
+ module DiscardableClient
73
+ # Close the persistent connection before dropping the reference.
74
+ # Nil-ing +@http_client+ alone abandons the open socket — the
75
+ # descriptor stays allocated until GC finalizes the object — so
76
+ # finish it first. Finish raises IOError on a session that was never
77
+ # started, and a connection that died mid-request can refuse to close
78
+ # cleanly; the rescue keeps the discard best-effort and only
79
+ # guarantees the reference is dropped.
80
+ #
81
+ # @return [void]
82
+ def discard_http_client
83
+ @http_client&.finish
84
+ rescue StandardError
85
+ nil
86
+ ensure
87
+ @http_client = nil
88
+ end
89
+ end
90
+
91
+ # Raised when an embedding API answers with a non-success HTTP
92
+ # response. Carries the HTTP status and the raw +Retry-After+ header
93
+ # (when the server sent one) so {Woods::Resilience::RetryableProvider}
94
+ # can distinguish transient failures (429 rate limits, 5xx) from
95
+ # hopeless ones (400 bad request, 401 bad credentials) and honor the
96
+ # server's requested back-off instead of its own schedule.
97
+ #
98
+ # Subclasses {Woods::Error}, so existing `rescue Woods::Error` call
99
+ # sites keep working unchanged.
100
+ class RequestError < Woods::Error
101
+ # @return [Integer, nil] HTTP status code of the failed response
102
+ attr_reader :http_status
103
+
104
+ # @return [String, nil] raw +Retry-After+ header value (delta-seconds
105
+ # or HTTP-date form), or nil when the server sent none
106
+ attr_reader :retry_after
107
+
108
+ # @param message [String] human-readable error message
109
+ # @param http_status [Integer, nil] HTTP status code
110
+ # @param retry_after [String, nil] raw +Retry-After+ header value
111
+ def initialize(message, http_status: nil, retry_after: nil)
112
+ super(message)
113
+ @http_status = http_status
114
+ @retry_after = retry_after
115
+ end
116
+ end
117
+
118
+ # Raised when a provider's embedding response is malformed: wrong
119
+ # cardinality, missing/duplicate OpenAI response indexes, a
120
+ # non-finite/non-numeric or empty vector, or a vector whose dimension
121
+ # disagrees with the rest of the batch. Left unvalidated, any of
122
+ # these stores nil or mis-paired vectors and silently corrupts the
123
+ # vector store.
124
+ class InvalidEmbeddingResponse < Woods::Error
125
+ # @return [String] the provider label ("OpenAI", "Ollama")
126
+ attr_reader :provider
127
+
128
+ # @return [Integer] the number of texts in the request this response answers
129
+ attr_reader :batch_size
130
+
131
+ # @param message [String] what specifically was wrong with the response
132
+ # @param provider [String] provider label
133
+ # @param batch_size [Integer] number of texts requested
134
+ def initialize(message, provider:, batch_size:)
135
+ @provider = provider
136
+ @batch_size = batch_size
137
+ super("#{provider} embedding response invalid for batch of #{batch_size}: #{message}")
138
+ end
139
+ end
140
+
141
+ # Shared response-shape validation for embedding providers. A short
142
+ # or malformed provider response — fewer vectors than requested,
143
+ # duplicate/missing OpenAI response indexes, a NaN/Infinity/nil
144
+ # entry, or a dimension that drifts partway through a batch — would
145
+ # otherwise store nil or mis-paired vectors with no error at all,
146
+ # corrupting the index silently. Every provider's `embed`/
147
+ # `embed_batch` must call {.validate!} before returning.
148
+ module VectorValidation
149
+ module_function
150
+
151
+ # @param vectors [Array<Array<Numeric>>] vectors about to be returned/stored,
152
+ # in whatever order the caller has them (order doesn't matter for these checks)
153
+ # @param expected_count [Integer] number of texts in the request
154
+ # @param provider [String] provider label used in the raised error's message
155
+ # @param indexes [Array<Integer>, nil] raw response `index` values, when the
156
+ # provider's wire format carries them (OpenAI). Ollama has no index field —
157
+ # its response order is positional — so callers pass nil there and this
158
+ # check is skipped.
159
+ # @raise [InvalidEmbeddingResponse] on any violation
160
+ # @return [void]
161
+ def validate!(vectors, expected_count:, provider:, indexes: nil)
162
+ fail_with = lambda do |msg|
163
+ raise InvalidEmbeddingResponse.new(msg, provider: provider, batch_size: expected_count)
164
+ end
165
+
166
+ unless vectors.size == expected_count
167
+ fail_with.call("expected #{expected_count} vector(s), got #{vectors.size}")
168
+ end
169
+
170
+ validate_indexes!(indexes, expected_count, fail_with) if indexes
171
+
172
+ validate_vector_shapes!(vectors, fail_with)
173
+ end
174
+
175
+ # @api private
176
+ def validate_indexes!(indexes, expected_count, fail_with)
177
+ if indexes.any? { |i| !i.is_a?(Integer) }
178
+ fail_with.call("response indexes must all be integers, got #{indexes.inspect}")
179
+ end
180
+ fail_with.call("response indexes are not unique: #{indexes.sort}") if indexes.uniq.size != indexes.size
181
+
182
+ expected_indexes = (0...expected_count).to_a
183
+ return if indexes.sort == expected_indexes
184
+
185
+ fail_with.call("response indexes #{indexes.sort} do not cover 0..#{expected_count - 1}")
186
+ end
187
+ private_class_method :validate_indexes!
188
+
189
+ # @api private
190
+ def validate_vector_shapes!(vectors, fail_with)
191
+ dimension = nil
192
+ vectors.each_with_index do |vector, i|
193
+ check_vector_shape!(vector, i, fail_with)
194
+ dimension ||= vector.size
195
+ next if vector.size == dimension
196
+
197
+ fail_with.call("vector at position #{i} has dimension #{vector.size}, expected #{dimension}")
198
+ end
199
+ end
200
+ private_class_method :validate_vector_shapes!
201
+
202
+ # @api private
203
+ def check_vector_shape!(vector, index, fail_with)
204
+ unless vector.is_a?(Array) && !vector.empty?
205
+ fail_with.call("vector at position #{index} is not a non-empty array (got #{vector.class})")
206
+ end
207
+ return if vector.all? { |n| n.is_a?(Numeric) && n.finite? }
208
+
209
+ fail_with.call("vector at position #{index} contains a non-finite or non-numeric value")
210
+ end
211
+ private_class_method :check_vector_shape!
212
+ end
213
+
64
214
  # Ollama adapter for local embeddings via the Ollama HTTP API.
65
215
  #
66
216
  # Uses the `/api/embed` endpoint to generate embeddings. Requires a running
@@ -72,6 +222,7 @@ module Woods
72
222
  # vectors = provider.embed_batch(["text1", "text2"])
73
223
  class Ollama
74
224
  include Interface
225
+ include DiscardableClient
75
226
 
76
227
  DEFAULT_MODEL = 'nomic-embed-text'
77
228
  DEFAULT_HOST = 'http://localhost:11434'
@@ -118,13 +269,15 @@ module Woods
118
269
  # context from `MODEL_CONTEXT_LENGTHS`, falling back to 2048 for
119
270
  # unknown models. Set explicitly only if running a model with a
120
271
  # known-larger native context that isn't in the registry yet.
272
+ # @param dimensions [Integer, nil] Requested output vector size.
121
273
  # @param read_timeout [Integer] HTTP read timeout in seconds.
122
274
  # Bump this for slow / cold-start hosts or very large batches.
123
275
  def initialize(model: DEFAULT_MODEL, host: DEFAULT_HOST, num_ctx: nil,
124
- read_timeout: DEFAULT_READ_TIMEOUT)
276
+ dimensions: nil, read_timeout: DEFAULT_READ_TIMEOUT)
125
277
  @model = model
126
278
  @host = host
127
279
  @num_ctx = num_ctx || MODEL_CONTEXT_LENGTHS.fetch(model, FALLBACK_NUM_CTX)
280
+ @dimensions = normalize_dimensions(dimensions)
128
281
  @read_timeout = read_timeout
129
282
  @uri = URI("#{host}/api/embed")
130
283
  end
@@ -139,7 +292,9 @@ module Woods
139
292
  raise ArgumentError, 'embed(text) requires a non-empty string' if text.nil? || text.to_s.strip.empty?
140
293
 
141
294
  response = post_request(build_body(text))
142
- response['embeddings'].first
295
+ vectors = Array(response['embeddings'])
296
+ VectorValidation.validate!(vectors, expected_count: 1, provider: 'Ollama')
297
+ vectors.first
143
298
  end
144
299
 
145
300
  # Embed multiple texts in a single request.
@@ -155,7 +310,9 @@ module Woods
155
310
  end
156
311
 
157
312
  response = post_request(build_body(texts))
158
- response['embeddings']
313
+ vectors = Array(response['embeddings'])
314
+ VectorValidation.validate!(vectors, expected_count: texts.size, provider: 'Ollama')
315
+ vectors
159
316
  end
160
317
 
161
318
  # Return the dimensionality of vectors produced by this model.
@@ -186,6 +343,15 @@ module Woods
186
343
 
187
344
  private
188
345
 
346
+ def normalize_dimensions(value)
347
+ return if value.nil?
348
+
349
+ dimensions = Integer(value)
350
+ raise ArgumentError, "dimensions must be positive, got #{value.inspect}" unless dimensions.positive?
351
+
352
+ dimensions
353
+ end
354
+
189
355
  # Cap interpolated response bodies so misconfigured Ollama responses
190
356
  # (e.g. proxied HTML error pages) don't unbounded-leak into logs or
191
357
  # re-raised error messages.
@@ -204,6 +370,7 @@ module Woods
204
370
  # tokens and returns 400 when the input exceeds that default.
205
371
  def build_body(input)
206
372
  body = { model: @model, input: input }
373
+ body[:dimensions] = @dimensions if @dimensions
207
374
  body[:options] = { num_ctx: @num_ctx } if @num_ctx
208
375
  body
209
376
  end
@@ -213,31 +380,42 @@ module Woods
213
380
  # @param body [Hash] request body
214
381
  # @return [Hash] parsed JSON response
215
382
  # @raise [Woods::Error] if the API returns a non-success status
216
- def post_request(body) # rubocop:disable Metrics/AbcSize
383
+ def post_request(body)
217
384
  request = Net::HTTP::Post.new(@uri.path, 'Content-Type' => 'application/json')
218
385
  request.body = body.to_json
219
386
  response = http_client.request(request)
220
387
 
221
- unless response.is_a?(Net::HTTPSuccess)
222
- raise Woods::Error, "Ollama API error: #{response.code} #{truncate_response_body(response.body)}"
223
- end
388
+ raise request_error(response) unless response.is_a?(Net::HTTPSuccess)
224
389
 
225
390
  JSON.parse(response.body)
226
391
  rescue Errno::ECONNRESET, Net::OpenTimeout, Net::ReadTimeout, IOError
227
392
  # Connection dropped — reset and retry once
228
- @http_client = nil
393
+ discard_http_client
229
394
  begin
230
395
  response = http_client.request(request)
231
396
  rescue StandardError => retry_error
232
397
  raise Woods::Error, "Ollama API error (retry failed): #{retry_error.message}"
233
398
  end
234
- unless response.is_a?(Net::HTTPSuccess)
235
- raise Woods::Error, "Ollama API error: #{response.code} #{truncate_response_body(response.body)}"
236
- end
399
+ raise request_error(response) unless response.is_a?(Net::HTTPSuccess)
237
400
 
238
401
  JSON.parse(response.body)
239
402
  end
240
403
 
404
+ # Build a {RequestError} from a non-success Ollama response,
405
+ # attaching the HTTP status and any +Retry-After+ header so the
406
+ # resilience layer can classify the failure and honor the
407
+ # server-requested back-off.
408
+ #
409
+ # @param response [Net::HTTPResponse]
410
+ # @return [RequestError]
411
+ def request_error(response)
412
+ RequestError.new(
413
+ "Ollama API error: #{response.code} #{truncate_response_body(response.body)}",
414
+ http_status: response.code.to_i,
415
+ retry_after: response['Retry-After']
416
+ )
417
+ end
418
+
241
419
  # Return a reusable, started HTTP client for the Ollama API.
242
420
  #
243
421
  # @return [Net::HTTP]
@@ -6,7 +6,7 @@ module Woods
6
6
  module Embedding
7
7
  # Prepares ExtractedUnit data for embedding by building context-prefixed text.
8
8
  #
9
- # Follows the context prefix format from docs/CONTEXT_AND_CHUNKING.md:
9
+ # Follows the context prefix format (see `git log --follow -- docs/design/CONTEXT_AND_CHUNKING.md`):
10
10
  # [type] identifier
11
11
  # namespace: ...
12
12
  # file: ...
@@ -58,13 +58,6 @@ module Woods
58
58
  tok ? tok.encode(text).ids.length : estimate(text)
59
59
  end
60
60
 
61
- # True when the real tokenizer is loaded and in use.
62
- #
63
- # @return [Boolean]
64
- def exact?
65
- !tokenizer.nil?
66
- end
67
-
68
61
  private
69
62
 
70
63
  def estimate(text)
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Woods
6
+ module Evaluation
7
+ # Parses the `claude -p --output-format json` shaped payload an ablation
8
+ # agent prints on stdout (#280).
9
+ module AblationAgentPayload
10
+ TOKEN_KEYS = %w[input_tokens output_tokens cache_creation_input_tokens cache_read_input_tokens].freeze
11
+
12
+ module_function
13
+
14
+ # The last line that parses as a JSON object wins, so a preamble on
15
+ # stdout does not hide the result.
16
+ #
17
+ # @param stdout [String]
18
+ # @return [Hash, nil]
19
+ def parse(stdout)
20
+ stdout.to_s.lines.reverse_each do |line|
21
+ parsed = JSON.parse(line)
22
+ return parsed if parsed.is_a?(Hash)
23
+ rescue JSON::ParserError
24
+ next
25
+ end
26
+ nil
27
+ end
28
+
29
+ # @param usage [Hash, nil]
30
+ # @return [Integer, nil]
31
+ def token_total(usage)
32
+ return nil unless usage.is_a?(Hash)
33
+
34
+ TOKEN_KEYS.sum { |key| usage[key].to_i }
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'timeout'
4
+
5
+ module Woods
6
+ module Evaluation
7
+ # Default executor for {AblationRunner}: spawns a real subprocess and
8
+ # exposes the pid of the most recent spawn via {#pid} (#280 review), so
9
+ # {AblationTimedExecutor} can terminate it on timeout. `Open3.capture3`
10
+ # blocks synchronously and never surfaces a pid to the caller, which is
11
+ # exactly the gap that let a timed-out agent keep running in the
12
+ # background.
13
+ class AblationExecutor
14
+ # @return [Integer, nil] the pid of the most recently spawned process
15
+ attr_reader :pid
16
+
17
+ # @param command [String] a full shell command line
18
+ # @param chdir [String]
19
+ # @return [Array(String, String, Boolean)] stdout, stderr, success
20
+ def call(command, chdir:)
21
+ stdout_r, stdout_w = IO.pipe
22
+ stderr_r, stderr_w = IO.pipe
23
+ spawn_child(command, chdir, stdout_w, stderr_w)
24
+ stdout_w.close
25
+ stderr_w.close
26
+
27
+ # Read concurrently with the wait, not after it: a pipe holds only
28
+ # ~64KB on Linux before a write blocks, so a child that writes more
29
+ # than that to either stream, with nothing draining it, blocks
30
+ # forever, and Process.wait2 alone never touches either pipe. That
31
+ # made AblationTimedExecutor report a false timeout on a chatty
32
+ # agent instead of ever reaching a real result.
33
+ stdout_thread = reader_thread(stdout_r)
34
+ stderr_thread = reader_thread(stderr_r)
35
+ _reaped_pid, status = Process.wait2(@pid)
36
+ [stdout_thread.value, stderr_thread.value, status.success?]
37
+ ensure
38
+ stdout_r&.close
39
+ stderr_r&.close
40
+ stdout_w&.close unless stdout_w&.closed?
41
+ stderr_w&.close unless stderr_w&.closed?
42
+ end
43
+
44
+ private
45
+
46
+ # A timeout wrapping #call can interrupt it mid-read, closing the pipe
47
+ # out from under whichever reader thread is still blocked on it. That
48
+ # IOError is expected and the caller already discards this call's
49
+ # result, so it should not be reported as an unhandled thread
50
+ # exception.
51
+ def reader_thread(io)
52
+ Thread.new { io.read }.tap { |thread| thread.report_on_exception = false }
53
+ end
54
+
55
+ # `Thread.handle_interrupt` defers the async Timeout::Error
56
+ # AblationTimedExecutor's Timeout.timeout can raise into this thread,
57
+ # so it cannot land between Process.spawn returning and `@pid` being
58
+ # set (#280 review, minor): a pid lost that way could never be
59
+ # terminated on timeout.
60
+ def spawn_child(command, chdir, stdout_w, stderr_w)
61
+ Thread.handle_interrupt(Timeout::Error => :never) do
62
+ @pid = Process.spawn(command, chdir: chdir, in: File::NULL, out: stdout_w, err: stderr_w)
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ module Woods
6
+ module Evaluation
7
+ # Per-trial provenance (a binding ruling): the exact agent command run,
8
+ # the model the agent reported (when the JSON payload names one), the
9
+ # MCP wiring in effect, the Woods generation on disk in the checkout,
10
+ # and the baseline SHA the checkout came from (#280).
11
+ AblationProvenance = Struct.new(:agent_command, :model, :config, :woods_generation, :baseline_sha,
12
+ keyword_init: true) do
13
+ # @return [AblationProvenance]
14
+ def self.build(agent_command:, chdir:, baseline_sha:)
15
+ new(agent_command: agent_command, model: nil, config: mcp_config_of(agent_command),
16
+ woods_generation: woods_generation_at(chdir), baseline_sha: baseline_sha)
17
+ end
18
+
19
+ def self.mcp_config_of(command)
20
+ return 'strict' if command.include?('--strict-mcp-config')
21
+
22
+ match = command.match(/--mcp-config[= ](\S+)/)
23
+ match && match[1]
24
+ end
25
+
26
+ def self.woods_generation_at(chdir)
27
+ generation_file = File.join(chdir, 'tmp', 'woods', 'generation.json')
28
+ return nil unless File.exist?(generation_file)
29
+
30
+ JSON.parse(File.read(generation_file))['number']
31
+ rescue StandardError
32
+ nil
33
+ end
34
+
35
+ private_class_method :mcp_config_of, :woods_generation_at
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'fileutils'
5
+
6
+ module Woods
7
+ module Evaluation
8
+ # Serializes an {AblationRunner::Report} to JSON and renders the terminal
9
+ # summary for `woods:evaluate:ablation` (#280).
10
+ class AblationReportWriter
11
+ # @param report [AblationRunner::Report]
12
+ def initialize(report)
13
+ @report = report
14
+ end
15
+
16
+ # @param path [String]
17
+ # @return [void]
18
+ def write(path)
19
+ FileUtils.mkdir_p(File.dirname(path))
20
+ results = @report.results.map { |result| result.to_h.merge(provenance: result.provenance.to_h) }
21
+ File.write(path, JSON.pretty_generate('summary' => @report.summary, 'results' => results))
22
+ end
23
+
24
+ # @return [String] a human-readable summary, explicit that this is a
25
+ # paired-run harness rather than a causal-evidence report
26
+ def summary_text
27
+ lines = ['', 'Ablation complete! This is a harness for paired runs, not causal evidence.', '=' * 50]
28
+ %i[on off delta].each { |condition| lines.concat(condition_lines(condition)) }
29
+ lines << ('=' * 50)
30
+ lines.join("\n")
31
+ end
32
+
33
+ private
34
+
35
+ def condition_lines(condition)
36
+ section = @report.summary[condition]
37
+ return [] unless section
38
+
39
+ [" #{condition}:"] + section.map { |key, value| " #{key.to_s.ljust(18)}: #{value.nil? ? 'n/a' : value}" }
40
+ end
41
+ end
42
+ end
43
+ end