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
@@ -3,12 +3,19 @@
3
3
  require 'ipaddr'
4
4
  require 'net/http'
5
5
  require 'json'
6
+ require 'openssl'
6
7
  require 'socket'
7
8
  require 'uri'
8
9
  require_relative 'vector_store'
9
10
  require_relative '../util/host_guard'
11
+ require_relative '../util/uuid5'
10
12
 
11
13
  module Woods
14
+ # Same conditional-define pattern used elsewhere in the gem (pgvector,
15
+ # metadata_store) so this file can be required in isolation without
16
+ # tripping NameError on the RequestError superclass below.
17
+ class Error < StandardError; end unless defined?(Woods::Error)
18
+
12
19
  module Storage
13
20
  module VectorStore
14
21
  # Qdrant adapter for vector storage and similarity search via HTTP API.
@@ -25,6 +32,26 @@ module Woods
25
32
  class Qdrant # rubocop:disable Metrics/ClassLength
26
33
  include Interface
27
34
 
35
+ class RequestError < Woods::Error
36
+ attr_reader :http_status, :retry_after
37
+
38
+ def initialize(message, http_status: nil, retry_after: nil, retryable: false, ambiguous: false)
39
+ super(message)
40
+ @http_status = http_status
41
+ @retry_after = retry_after
42
+ @retryable = retryable
43
+ @ambiguous = ambiguous
44
+ end
45
+
46
+ def retryable?
47
+ @retryable
48
+ end
49
+
50
+ def ambiguous?
51
+ @ambiguous
52
+ end
53
+ end
54
+
28
55
  # URL schemes allowed for the Qdrant endpoint. `file://`, `gopher://`,
29
56
  # and anything else would let a misconfigured or attacker-controlled
30
57
  # config value turn the adapter into an SSRF vector against the host
@@ -67,6 +94,54 @@ module Woods
67
94
  # Hostnames that always map to loopback regardless of DNS.
68
95
  PRIVATE_HOSTNAMES = %w[localhost localhost. ip6-localhost ip6-loopback].freeze
69
96
 
97
+ # Fixed UUIDv5 namespace for Woods → Qdrant point ids.
98
+ #
99
+ # Qdrant only accepts an unsigned integer or a UUID as a point id;
100
+ # a Woods identifier ("User", "Api::V1::UsersController",
101
+ # "User#chunk_0") is neither, and sending one back is a 400 on
102
+ # every upsert. Point ids are therefore UUIDv5 values derived from
103
+ # the identifier, with the identifier itself carried in the payload
104
+ # under {IDENTIFIER_KEY} so search and delete can round-trip.
105
+ #
106
+ # Derived once as
107
+ # Util::UUID5.generate(Util::UUID5::NAMESPACE_DNS, 'woods.qdrant.point-id')
108
+ # and pinned as a literal here. `spec/util/uuid5_spec.rb` asserts
109
+ # the literal still equals that derivation.
110
+ #
111
+ # **This value must never change.** The whole point of a v5 id is
112
+ # that re-embedding an unchanged unit lands on the same point and
113
+ # *replaces* it. A new namespace makes every existing point
114
+ # unreachable — orphaned vectors that no delete can name and a
115
+ # silently doubled collection.
116
+ POINT_ID_NAMESPACE = '7eb8ae2b-670b-55ee-a474-36bd1a8dc6b4'
117
+
118
+ # Query string appended to every mutating point operation.
119
+ #
120
+ # Qdrant defaults these endpoints to `wait=false`: the API returns
121
+ # `status: "acknowledged"` as soon as the change is queued, before it is
122
+ # readable. Every caller in this gem assumes otherwise — the embed
123
+ # pipeline writes vectors and then dumps/verifies, and the prune paths
124
+ # delete and then re-count — so an un-awaited mutation reads back as
125
+ # stale data (a deleted unit still answering searches, a fresh count
126
+ # showing the pre-write total). Correctness beats the throughput the
127
+ # async default buys.
128
+ WAIT_FOR_WRITE = '?wait=true'
129
+
130
+ # Points per page when scrolling ids in {#each_id}. Large enough that a
131
+ # sizable index costs few round trips, small enough that one response
132
+ # stays comfortably in memory (ids and one payload key only).
133
+ SCROLL_PAGE_SIZE = 1_000
134
+ DISTANCES = %w[Cosine Dot Euclid Manhattan].freeze
135
+
136
+ # Payload key holding the original Woods identifier for a point.
137
+ #
138
+ # Deliberately NOT `identifier`: the embedding Indexer already
139
+ # writes an `identifier` key holding the unit's *base* identifier,
140
+ # while a point id is derived from the possibly chunk-suffixed
141
+ # embed id ("User#chunk_0"). Reusing the key would clobber one
142
+ # with the other.
143
+ IDENTIFIER_KEY = 'woods_identifier'
144
+
70
145
  # @param url [String] Qdrant server URL
71
146
  # @param collection [String] Collection name
72
147
  # @param api_key [String, nil] Optional API key for authentication
@@ -80,12 +155,13 @@ module Woods
80
155
  # by default to block the common SSRF footgun. Set to true when the
81
156
  # operator intentionally runs Qdrant on `localhost:6333` or inside
82
157
  # a private network.
83
- def initialize(url:, collection:, api_key: nil, dimensions: nil, allow_private_hosts: false)
158
+ def initialize(url:, collection:, api_key: nil, dimensions: nil, distance: 'Cosine', allow_private_hosts: false) # rubocop:disable Metrics/ParameterLists
84
159
  @uri = self.class.validate_url!(url, allow_private_hosts: allow_private_hosts)
85
160
  @url = url
86
161
  @collection = collection
87
162
  @api_key = api_key
88
163
  @dimensions = dimensions
164
+ @distance = normalize_distance(distance)
89
165
  end
90
166
 
91
167
  # Validate a Qdrant endpoint URL — scheme in {ALLOWED_SCHEMES} and,
@@ -185,34 +261,49 @@ module Woods
185
261
  #
186
262
  # @param dimensions [Integer] Vector dimensionality
187
263
  def ensure_collection!(dimensions:)
188
- @dimensions ||= dimensions
189
- body = {
190
- vectors: {
191
- size: dimensions,
192
- distance: 'Cosine'
193
- }
194
- }
195
- request(:put, "/collections/#{@collection}", body)
264
+ dimensions = normalize_dimensions(dimensions)
265
+ validate_configured_dimensions!(dimensions)
266
+ @dimensions = dimensions
267
+ existing = request(:get, "/collections/#{@collection}")
268
+ verify_collection_dimensions!(existing, dimensions)
269
+ true
270
+ rescue RequestError => e
271
+ raise unless e.http_status == 404
272
+
273
+ create_collection!(dimensions)
274
+ true
275
+ end
276
+
277
+ # Deterministic Qdrant point id for a Woods identifier.
278
+ #
279
+ # Integers and canonical UUIDs pass through untouched — those are
280
+ # already native Qdrant point ids, so a caller holding one (from a
281
+ # raw scroll, say) can address the point directly. Everything else
282
+ # is a Woods identifier and becomes its UUIDv5.
283
+ #
284
+ # @param identifier [String, Integer]
285
+ # @return [String, Integer]
286
+ def self.point_id(identifier)
287
+ return identifier if identifier.is_a?(Integer)
288
+ return identifier if Util::UUID5.uuid?(identifier)
289
+
290
+ Util::UUID5.generate(POINT_ID_NAMESPACE, identifier)
196
291
  end
197
292
 
198
293
  # Store or update a vector with metadata payload.
199
294
  #
295
+ # The point id sent to Qdrant is {.point_id}(id), not +id+ itself;
296
+ # the original identifier travels in the payload under
297
+ # {IDENTIFIER_KEY} so {#search} can map results back.
298
+ #
200
299
  # @param id [String] Unique identifier
201
300
  # @param vector [Array<Float>] The embedding vector
202
301
  # @param metadata [Hash] Optional payload metadata
203
302
  # @see Interface#store
204
303
  def store(id, vector, metadata = {})
205
304
  validate_dimensions!(vector) if @dimensions
206
- body = {
207
- points: [
208
- {
209
- id: id,
210
- vector: vector,
211
- payload: metadata
212
- }
213
- ]
214
- }
215
- request(:put, "/collections/#{@collection}/points", body)
305
+ body = { points: [build_point(id, vector, metadata)] }
306
+ request(:put, "/collections/#{@collection}/points#{WAIT_FOR_WRITE}", body)
216
307
  end
217
308
 
218
309
  # Store multiple vectors in a single batch upsert request.
@@ -236,21 +327,29 @@ module Woods
236
327
  end
237
328
 
238
329
  body = {
239
- points: entries.map do |entry|
240
- { id: entry[:id], vector: entry[:vector], payload: entry[:metadata] || {} }
241
- end
330
+ points: entries.map { |entry| build_point(entry[:id], entry[:vector], entry[:metadata] || {}) }
242
331
  }
243
- request(:put, "/collections/#{@collection}/points", body)
332
+ request(:put, "/collections/#{@collection}/points#{WAIT_FOR_WRITE}", body)
244
333
  end
245
334
 
246
335
  # Search for similar vectors.
247
336
  #
337
+ # The query vector is dimension-checked before the request, mirroring
338
+ # the upsert path: otherwise a wrong-dimension query surfaces as a
339
+ # Qdrant 400 instead of the typed Woods::Error callers already handle
340
+ # from {#store}/{#store_batch}.
341
+ #
248
342
  # @param query_vector [Array<Float>] The query embedding
249
343
  # @param limit [Integer] Maximum results to return
250
344
  # @param filters [Hash] Metadata key-value filters
251
- # @return [Array<SearchResult>] Results sorted by descending similarity
345
+ # @return [Array<SearchResult>] Results sorted by descending similarity,
346
+ # with +id+ carrying the Woods identifier (not the UUID point id)
347
+ # so the adapter is interchangeable with pgvector downstream.
348
+ # @raise [Woods::Error] if the query vector's length disagrees with the
349
+ # configured dimension
252
350
  # @see Interface#search
253
351
  def search(query_vector, limit: 10, filters: {})
352
+ validate_dimensions!(query_vector) if @dimensions
254
353
  body = {
255
354
  vector: query_vector,
256
355
  limit: limit,
@@ -262,24 +361,88 @@ module Woods
262
361
  results = response['result'] || []
263
362
 
264
363
  results.map do |hit|
364
+ payload = hit['payload'] || {}
265
365
  SearchResult.new(
266
- id: hit['id'],
366
+ # Reverse-map the UUID point id back to the Woods identifier.
367
+ # Falls back to the raw point id for points written by
368
+ # something other than this adapter (or before the UUID
369
+ # mapping existed) — a hit with no reverse mapping is still
370
+ # more useful than nil.
371
+ id: payload[IDENTIFIER_KEY] || hit['id'],
267
372
  score: hit['score'],
268
- metadata: hit['payload']
373
+ metadata: payload
269
374
  )
270
375
  end
271
376
  end
272
377
 
378
+ # The vector width the collection was actually created with.
379
+ #
380
+ # `ensure_collection!` is a PUT that Qdrant treats as idempotent, so an
381
+ # existing collection keeps whatever width it was built with; a
382
+ # dimension change then surfaces as a 400 on every upsert. Reading it
383
+ # back lets the pipeline refuse up front with a re-index remedy (#214).
384
+ #
385
+ # @return [Integer, nil] the collection's vector size, or nil when the
386
+ # collection does not exist or the shape is unrecognized (named
387
+ # vectors, for instance, which this adapter does not write)
388
+ def stored_dimensions
389
+ extract_dimensions(request(:get, "/collections/#{@collection}"))
390
+ rescue RequestError => e
391
+ raise unless e.http_status == 404
392
+
393
+ nil
394
+ end
395
+
396
+ # Iterate over every stored id, yielding the Woods identifier.
397
+ #
398
+ # Uses the scroll API with `with_vector: false` and only the identifier
399
+ # key requested, so reconciliation costs payload-sized pages rather
400
+ # than whole vectors. Yields the same value {#search} returns as `id`
401
+ # (the Woods identifier, reverse-mapped from the payload) so callers
402
+ # can compare against extraction output directly, and so any id yielded
403
+ # here can be handed straight back to {#delete}.
404
+ #
405
+ # Points written by something else — no {IDENTIFIER_KEY} in their
406
+ # payload — are **skipped**, not yielded (STO-2). A collection may be
407
+ # shared with another writer; an unattributable point can never appear
408
+ # in extraction output, so yielding it would make the embed pipeline's
409
+ # reconciliation sweep read it as a vanished unit and delete another
410
+ # system's vector on every run. Enumerating only what Woods wrote is
411
+ # the property callers depend on. {#search} still falls back to the raw
412
+ # id, because there the point was matched, not swept.
413
+ #
414
+ # @see Interface#each_id
415
+ def each_id(&block)
416
+ return enum_for(:each_id) unless block
417
+
418
+ offset = nil
419
+ loop do
420
+ points, offset = scroll_page(offset)
421
+ points.each do |point|
422
+ identifier = woods_identifier_for(point)
423
+ yield(identifier) if identifier
424
+ end
425
+ break if offset.nil?
426
+ end
427
+ end
428
+
429
+ # Delete a single point by Woods identifier.
430
+ #
431
+ # Translates through {.point_id}, so it necessarily computes the
432
+ # same id {#store}/{#store_batch} wrote. Deleting by the raw Woods
433
+ # string would 400 (or, worse, succeed against nothing) and leave
434
+ # the vector live — silent data retention.
435
+ #
273
436
  # @see Interface#delete
274
437
  def delete(id)
275
- body = { points: [id] }
276
- request(:post, "/collections/#{@collection}/points/delete", body)
438
+ body = { points: [self.class.point_id(id)] }
439
+ request(:post, "/collections/#{@collection}/points/delete#{WAIT_FOR_WRITE}", body)
277
440
  end
278
441
 
279
442
  # @see Interface#delete_by_filter
280
443
  def delete_by_filter(filters)
281
444
  body = { filter: build_filter(filters) }
282
- request(:post, "/collections/#{@collection}/points/delete", body)
445
+ request(:post, "/collections/#{@collection}/points/delete#{WAIT_FOR_WRITE}", body)
283
446
  end
284
447
 
285
448
  # @see Interface#count
@@ -290,6 +453,105 @@ module Woods
290
453
 
291
454
  private
292
455
 
456
+ def normalize_dimensions(value)
457
+ dimensions = Integer(value)
458
+ raise ArgumentError, 'dimensions must be positive' unless dimensions.positive?
459
+
460
+ dimensions
461
+ end
462
+
463
+ def normalize_distance(value)
464
+ distance = DISTANCES.find { |candidate| candidate.casecmp?(value.to_s) }
465
+ return distance if distance
466
+
467
+ raise ArgumentError, "distance must be one of #{DISTANCES.join(', ')}"
468
+ end
469
+
470
+ def validate_configured_dimensions!(dimensions)
471
+ return unless @dimensions && Integer(@dimensions) != dimensions
472
+
473
+ raise Woods::ConfigurationError,
474
+ "Qdrant dimension mismatch: Builder requested #{dimensions}, " \
475
+ "but vector_store_options configured #{@dimensions}"
476
+ end
477
+
478
+ def verify_collection_dimensions!(response, dimensions)
479
+ vectors = response.dig('result', 'config', 'params', 'vectors')
480
+ unless vectors.is_a?(Hash) && vectors['size']
481
+ raise Woods::ConfigurationError,
482
+ "Qdrant collection #{@collection.inspect} uses named vectors; named vectors are not supported " \
483
+ 'by this unnamed-vector adapter.'
484
+ end
485
+ existing_dimensions = vectors['size']
486
+ if existing_dimensions != dimensions
487
+ raise Woods::ConfigurationError,
488
+ "Qdrant collection dimension mismatch: configured #{dimensions}, existing #{existing_dimensions}. " \
489
+ 'Use a new collection or rebuild the existing collection.'
490
+ end
491
+ return if vectors['distance'] == @distance
492
+
493
+ raise Woods::ConfigurationError,
494
+ "Qdrant collection distance mismatch: configured #{@distance}, existing #{vectors['distance']}. " \
495
+ 'Use a new collection or rebuild the existing collection.'
496
+ end
497
+
498
+ def create_collection!(dimensions)
499
+ request(
500
+ :put,
501
+ "/collections/#{@collection}",
502
+ vectors: { size: dimensions, distance: @distance }
503
+ )
504
+ end
505
+
506
+ def extract_dimensions(response)
507
+ config = response.dig('result', 'config', 'params', 'vectors')
508
+ return unless config.is_a?(Hash)
509
+
510
+ size = config['size']
511
+ size if size.is_a?(Integer) && size.positive?
512
+ end
513
+
514
+ # Fetch one page of the scroll cursor.
515
+ #
516
+ # @param offset [Object, nil] the cursor from the previous page
517
+ # @return [Array(Array<Hash>, Object)] the page's points and the next
518
+ # offset (nil when the scroll is exhausted)
519
+ def scroll_page(offset)
520
+ body = { limit: SCROLL_PAGE_SIZE, with_payload: [IDENTIFIER_KEY], with_vector: false }
521
+ body[:offset] = offset if offset
522
+
523
+ result = request(:post, "/collections/#{@collection}/points/scroll", body)['result'] || {}
524
+ [result['points'] || [], result['next_page_offset']]
525
+ end
526
+
527
+ # The Woods identifier a scrolled point carries, or nil when the point
528
+ # was not written by this adapter.
529
+ #
530
+ # @param point [Hash] a scroll-result point
531
+ # @return [String, nil]
532
+ def woods_identifier_for(point)
533
+ (point['payload'] || {})[IDENTIFIER_KEY]
534
+ end
535
+
536
+ # Build one Qdrant point: UUIDv5 id, vector, and a payload carrying
537
+ # the original Woods identifier alongside the caller's metadata.
538
+ #
539
+ # The identifier is merged in rather than replacing the payload, and
540
+ # written last so a metadata hash that already carries the key can't
541
+ # break the reverse mapping. Symbol and string metadata keys both
542
+ # serialize to JSON strings, so the string key here is what Qdrant
543
+ # stores either way.
544
+ #
545
+ # @param id [String, Integer] Woods identifier (or native point id)
546
+ # @param vector [Array<Float>]
547
+ # @param metadata [Hash]
548
+ # @return [Hash]
549
+ def build_point(id, vector, metadata)
550
+ { id: self.class.point_id(id),
551
+ vector: vector,
552
+ payload: (metadata || {}).merge(IDENTIFIER_KEY => id) }
553
+ end
554
+
293
555
  # Cap interpolated response bodies so misconfigured Qdrant responses
294
556
  # (e.g. proxied HTML error pages) don't unbounded-leak into logs or
295
557
  # re-raised error messages.
@@ -341,22 +603,70 @@ module Woods
341
603
  # @raise [Woods::Error] if the API returns a non-success status
342
604
  def request(method, path, body = nil)
343
605
  req = build_request(method, path, body)
344
- response = http_client.request(req)
345
-
346
- unless response.is_a?(Net::HTTPSuccess)
347
- raise Woods::Error, "Qdrant API error: #{response.code} #{truncate_response_body(response.body)}"
606
+ attempt = 0
607
+
608
+ begin
609
+ attempt += 1
610
+ response = http_client.request(req)
611
+ parse_response(response, path)
612
+ rescue OpenSSL::SSL::SSLError => e
613
+ discard_http_client
614
+ raise RequestError, "Qdrant TLS error: #{e.message}"
615
+ rescue Errno::ECONNREFUSED, SocketError, Net::OpenTimeout => e
616
+ discard_http_client
617
+ retry if attempt == 1
618
+
619
+ raise transport_error(e, ambiguous: false)
620
+ rescue Errno::ECONNRESET, Net::ReadTimeout, Net::WriteTimeout, IOError => e
621
+ discard_http_client
622
+ retry if attempt == 1 && !write_request?(method, path)
623
+
624
+ raise transport_error(e, ambiguous: write_request?(method, path))
348
625
  end
626
+ end
349
627
 
350
- JSON.parse(response.body)
351
- rescue Errno::ECONNRESET, Net::OpenTimeout, IOError
352
- # Connection dropped — reset and retry once
353
- @http_client = nil
354
- response = http_client.request(req)
355
- unless response.is_a?(Net::HTTPSuccess)
356
- raise Woods::Error, "Qdrant API error: #{response.code} #{truncate_response_body(response.body)}"
357
- end
628
+ def parse_response(response, path)
629
+ raise response_error(response) unless response.is_a?(Net::HTTPSuccess)
358
630
 
359
631
  JSON.parse(response.body)
632
+ rescue JSON::ParserError => e
633
+ raise RequestError.new(
634
+ "Qdrant API returned malformed JSON for #{path}: #{e.message}",
635
+ http_status: response.code.to_i
636
+ )
637
+ end
638
+
639
+ def response_error(response)
640
+ status = response.code.to_i
641
+ RequestError.new(
642
+ "Qdrant API error: #{status} #{truncate_response_body(response.body)}",
643
+ http_status: status,
644
+ retry_after: response['Retry-After'],
645
+ retryable: status == 408 || status == 429 || status >= 500
646
+ )
647
+ end
648
+
649
+ def transport_error(error, ambiguous:)
650
+ RequestError.new(
651
+ "Qdrant transport error: #{error.class}: #{error.message}",
652
+ retryable: true,
653
+ ambiguous: ambiguous
654
+ )
655
+ end
656
+
657
+ # Only GET and read-only POST (search/count/scroll) retry a
658
+ # mid-exchange network failure — every other verb, and every other
659
+ # POST (upsert, delete), may already have committed server-side, so
660
+ # a blind retry risks double-applying it.
661
+ def write_request?(method, path)
662
+ return false if method == :get
663
+ return false if method == :post && read_only_post?(path)
664
+
665
+ true
666
+ end
667
+
668
+ def read_only_post?(path)
669
+ %w[/search /count /scroll].any? { |suffix| path.split('?').first.end_with?(suffix) }
360
670
  end
361
671
 
362
672
  # Return a reusable, started HTTP client for the Qdrant server.
@@ -376,6 +686,22 @@ module Woods
376
686
  @http_client = http
377
687
  end
378
688
 
689
+ # Close a persistent connection before dropping the reference. Nil-ing
690
+ # {#http_client} alone abandons the open socket — the descriptor stays
691
+ # allocated until GC finalizes the object — so finish it first. Finish
692
+ # raises IOError on a session that was never started, and a connection
693
+ # that died mid-request can refuse to close cleanly; the rescue keeps
694
+ # the discard best-effort and only guarantees the reference is dropped.
695
+ #
696
+ # @return [void]
697
+ def discard_http_client
698
+ @http_client&.finish
699
+ rescue StandardError
700
+ nil
701
+ ensure
702
+ @http_client = nil
703
+ end
704
+
379
705
  # Build an HTTP request with headers and body.
380
706
  #
381
707
  # @param method [Symbol] HTTP method