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
@@ -4,6 +4,12 @@ require 'json'
4
4
  require_relative 'vector_store'
5
5
 
6
6
  module Woods
7
+ # Same conditional-define pattern used elsewhere in the gem so this
8
+ # file can be required in isolation (e.g. by specs that bypass the
9
+ # full lib/woods.rb load) without tripping NameError on the
10
+ # dimension-mismatch and batch-conflict raises below.
11
+ class Error < StandardError; end unless defined?(Woods::Error)
12
+
7
13
  module Storage
8
14
  module VectorStore
9
15
  # PostgreSQL + pgvector adapter for vector storage and similarity search.
@@ -21,31 +27,41 @@ module Woods
21
27
  include Interface
22
28
 
23
29
  TABLE = 'woods_vectors'
30
+ TABLE_NAME_PATTERN = /\A[a-z_][a-z0-9_]*\z/
31
+
32
+ attr_reader :table, :schema, :dimensions
24
33
 
25
34
  # @param connection [Object] ActiveRecord database connection
26
35
  # @param dimensions [Integer] Size of the embedding vectors
27
- def initialize(connection:, dimensions:)
36
+ # @param table [String] PostgreSQL table name
37
+ def initialize(connection:, dimensions:, table: TABLE, schema: nil)
28
38
  @connection = connection
29
- @dimensions = dimensions
39
+ @dimensions = normalize_dimensions(dimensions)
40
+ @table = table.to_s
41
+ @schema = schema&.to_s
42
+ validate_identifier!(:table, @table, table)
43
+ validate_identifier!(:schema, @schema, schema) if @schema
30
44
  end
31
45
 
32
46
  # Create the pgvector extension, vectors table, and HNSW index.
33
47
  #
34
48
  # Safe to call multiple times (uses IF NOT EXISTS).
35
49
  def ensure_schema!
36
- @connection.execute('CREATE EXTENSION IF NOT EXISTS vector')
37
- @connection.execute(<<~SQL)
38
- CREATE TABLE IF NOT EXISTS #{TABLE} (
39
- id TEXT PRIMARY KEY,
40
- embedding vector(#{@dimensions}),
41
- metadata JSONB DEFAULT '{}',
42
- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
43
- )
44
- SQL
45
- @connection.execute(<<~SQL)
46
- CREATE INDEX IF NOT EXISTS idx_#{TABLE}_embedding_hnsw
47
- ON #{TABLE} USING hnsw (embedding vector_cosine_ops)
48
- SQL
50
+ @connection.transaction do
51
+ @connection.execute('CREATE EXTENSION IF NOT EXISTS vector')
52
+ @connection.execute(<<~SQL)
53
+ CREATE TABLE IF NOT EXISTS #{qualified_table} (
54
+ id TEXT PRIMARY KEY,
55
+ embedding vector(#{@dimensions}),
56
+ metadata JSONB DEFAULT '{}',
57
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
58
+ )
59
+ SQL
60
+ @connection.execute(<<~SQL)
61
+ CREATE INDEX IF NOT EXISTS idx_#{@table}_embedding_hnsw
62
+ ON #{qualified_table} USING hnsw (embedding vector_cosine_ops)
63
+ SQL
64
+ end
49
65
  end
50
66
 
51
67
  # Store or update a vector with metadata.
@@ -60,7 +76,7 @@ module Woods
60
76
  entry = format_entry(id, vector, metadata)
61
77
 
62
78
  @connection.execute(<<~SQL)
63
- INSERT INTO #{TABLE} (id, embedding, metadata, created_at)
79
+ INSERT INTO #{qualified_table} (id, embedding, metadata, created_at)
64
80
  VALUES #{entry}
65
81
  ON CONFLICT (id) DO UPDATE SET
66
82
  embedding = EXCLUDED.embedding,
@@ -71,9 +87,17 @@ module Woods
71
87
 
72
88
  # Store multiple vectors in a single multi-row INSERT.
73
89
  #
90
+ # Duplicate ids within one batch are collapsed to the LAST occurrence
91
+ # (upsert semantics) before the statement is built — a multi-row
92
+ # `INSERT ... ON CONFLICT (id) DO UPDATE` cannot touch the same row
93
+ # twice, and PostgreSQL's `PG::CardinalityViolation` names no id and
94
+ # lands after earlier batches have already been written (#181).
95
+ #
74
96
  # @param entries [Array<Hash>] Each entry has :id, :vector, :metadata keys
75
97
  # @raise [ArgumentError] if any entry has a non-numeric or wrong-dimension vector.
76
98
  # Validation runs BEFORE any INSERT so partial-batch writes can't occur.
99
+ # @raise [Woods::Error] if PostgreSQL still reports a cardinality or
100
+ # uniqueness violation — re-raised naming every id in the batch.
77
101
  def store_batch(entries)
78
102
  return if entries.empty?
79
103
 
@@ -85,10 +109,11 @@ module Woods
85
109
  validate_dimensions!(vector, index: idx) if @dimensions
86
110
  end
87
111
 
112
+ entries = deduplicate_entries(entries)
88
113
  values = entries.map { |entry| format_entry(entry[:id], entry[:vector], entry[:metadata] || {}) }
89
114
 
90
- @connection.execute(<<~SQL)
91
- INSERT INTO #{TABLE} (id, embedding, metadata, created_at)
115
+ execute_upsert(<<~SQL, entries)
116
+ INSERT INTO #{qualified_table} (id, embedding, metadata, created_at)
92
117
  VALUES #{values.join(",\n")}
93
118
  ON CONFLICT (id) DO UPDATE SET
94
119
  embedding = EXCLUDED.embedding,
@@ -99,19 +124,27 @@ module Woods
99
124
 
100
125
  # Search for similar vectors using cosine distance.
101
126
  #
127
+ # The query vector is dimension-checked before SQL runs, mirroring the
128
+ # upsert path: otherwise a wrong-dimension query surfaces as a
129
+ # server-side PG::DataException instead of the typed Woods::Error
130
+ # callers already handle from {#store_batch}.
131
+ #
102
132
  # @param query_vector [Array<Float>] The query embedding
103
133
  # @param limit [Integer] Maximum results to return
104
134
  # @param filters [Hash] Metadata key-value filters
105
135
  # @return [Array<SearchResult>] Results sorted by descending similarity
136
+ # @raise [Woods::Error] if the query vector's length disagrees with the
137
+ # configured dimension
106
138
  # @see Interface#search
107
139
  def search(query_vector, limit: 10, filters: {})
108
140
  validate_vector!(query_vector)
141
+ validate_dimensions!(query_vector) if @dimensions
109
142
  vector_literal = build_vector_literal(query_vector)
110
143
  where_clause = build_where(filters)
111
144
 
112
145
  sql = <<~SQL
113
146
  SELECT id, embedding <=> '#{vector_literal}' AS distance, metadata
114
- FROM #{TABLE}
147
+ FROM #{qualified_table}
115
148
  #{where_clause}
116
149
  ORDER BY distance ASC
117
150
  LIMIT #{limit.to_i}
@@ -121,26 +154,151 @@ module Woods
121
154
  rows.map { |row| row_to_result(row) }
122
155
  end
123
156
 
157
+ # The vector width the table was actually created with.
158
+ #
159
+ # `ensure_schema!` is `CREATE TABLE IF NOT EXISTS`, so a table built by
160
+ # an earlier run at a different width is left exactly as it was — and
161
+ # the mismatch only surfaces later, as a PG::DataException on the first
162
+ # insert ("expected 384 dimensions, not 768"), after the run has already
163
+ # paid to embed everything. Reading it back lets the pipeline refuse up
164
+ # front with a re-index remedy (#214).
165
+ #
166
+ # For pgvector's `vector` type, `atttypmod` carries the dimension
167
+ # directly (unlike varchar, which offsets it).
168
+ #
169
+ # @return [Integer, nil] the column's dimension, or nil when the table
170
+ # does not exist yet or the width cannot be determined
171
+ def stored_dimensions
172
+ rows = @connection.execute(<<~SQL)
173
+ SELECT a.atttypmod AS dimension
174
+ FROM pg_attribute a
175
+ JOIN pg_class c ON c.oid = a.attrelid
176
+ JOIN pg_namespace n ON n.oid = c.relnamespace
177
+ WHERE c.relname = #{@connection.quote(@table)}
178
+ AND n.nspname = #{schema_sql}
179
+ AND a.attname = 'embedding' AND a.attnum > 0
180
+ SQL
181
+ row = rows.first
182
+ return nil unless row
183
+
184
+ dimension = row['dimension'].to_i
185
+ dimension.positive? ? dimension : nil
186
+ end
187
+
188
+ # Iterate over every stored id without loading vectors.
189
+ #
190
+ # One `SELECT id` — the embed pipeline uses this to reconcile the
191
+ # durable store against the units extraction still holds, and paying
192
+ # for the vector columns to answer a question about ids would be
193
+ # pointless IO.
194
+ #
195
+ # @see Interface#each_id
196
+ def each_id(&block)
197
+ return enum_for(:each_id) unless block
198
+
199
+ rows = @connection.execute("SELECT id FROM #{qualified_table}")
200
+ rows.each { |row| yield(row['id']) }
201
+ end
202
+
124
203
  # @see Interface#delete
125
204
  def delete(id)
126
205
  quoted_id = @connection.quote(id)
127
- @connection.execute("DELETE FROM #{TABLE} WHERE id = #{quoted_id}")
206
+ @connection.execute("DELETE FROM #{qualified_table} WHERE id = #{quoted_id}")
128
207
  end
129
208
 
130
209
  # @see Interface#delete_by_filter
131
210
  def delete_by_filter(filters)
132
211
  where_clause = build_where(filters)
133
- @connection.execute("DELETE FROM #{TABLE} #{where_clause}")
212
+ @connection.execute("DELETE FROM #{qualified_table} #{where_clause}")
134
213
  end
135
214
 
136
215
  # @see Interface#count
137
216
  def count
138
- result = @connection.execute("SELECT COUNT(*) AS count FROM #{TABLE}")
217
+ result = @connection.execute("SELECT COUNT(*) AS count FROM #{qualified_table}")
139
218
  result.first['count'].to_i
140
219
  end
141
220
 
142
221
  private
143
222
 
223
+ def normalize_dimensions(value)
224
+ return value if value.is_a?(Integer) && value.positive?
225
+
226
+ raise ArgumentError, 'dimensions must be a positive Integer'
227
+ end
228
+
229
+ def validate_identifier!(name, value, original)
230
+ return if TABLE_NAME_PATTERN.match?(value)
231
+
232
+ raise ArgumentError, "#{name} must be a PostgreSQL identifier, got #{original.inspect}"
233
+ end
234
+
235
+ def quote_identifier(value)
236
+ @connection.respond_to?(:quote_table_name) ? @connection.quote_table_name(value) : value
237
+ end
238
+
239
+ def qualified_table
240
+ [@schema, @table].compact.map { |part| quote_identifier(part) }.join('.')
241
+ end
242
+
243
+ def schema_sql
244
+ @schema ? @connection.quote(@schema) : 'current_schema()'
245
+ end
246
+
247
+ # Collapse duplicate ids within a batch, keeping the LAST occurrence
248
+ # of each id (upsert semantics — the final write wins). Duplicates
249
+ # signal an upstream problem (cross-type identifier twins, orphaned
250
+ # files re-embedding one unit twice), so dropped ids are logged
251
+ # rather than silently discarded.
252
+ #
253
+ # @param entries [Array<Hash>] Batch entries with :id keys
254
+ # @return [Array<Hash>] Entries with at most one row per id, original
255
+ # order preserved
256
+ def deduplicate_entries(entries)
257
+ deduped = entries.reverse.uniq { |entry| entry[:id] }.reverse
258
+ return entries if deduped.length == entries.length
259
+
260
+ duplicated = entries.group_by { |entry| entry[:id] }
261
+ .select { |_, occurrences| occurrences.length > 1 }
262
+ .keys
263
+ warn '[Woods::Storage::Pgvector] store_batch received duplicate ids ' \
264
+ "(keeping the last occurrence of each): #{duplicated.join(', ')}"
265
+ deduped
266
+ end
267
+
268
+ # Execute the multi-row upsert, converting a cardinality/uniqueness
269
+ # violation that still escapes the in-batch dedup into a
270
+ # {Woods::Error} naming every id in the batch — the raw PostgreSQL
271
+ # error names no id, which makes a partially-landed embed (earlier
272
+ # batches already committed) very hard to diagnose.
273
+ #
274
+ # @param sql [String] The INSERT ... ON CONFLICT statement
275
+ # @param entries [Array<Hash>] The batch, used for the error message
276
+ # @raise [Woods::Error] on a cardinality or uniqueness violation
277
+ def execute_upsert(sql, entries)
278
+ @connection.execute(sql)
279
+ rescue StandardError => e
280
+ raise unless conflict_violation?(e)
281
+
282
+ ids = entries.map { |entry| entry[:id] }
283
+ raise Woods::Error,
284
+ "pgvector batch upsert hit a duplicate-row conflict (#{e.class}: #{e.message.strip}). " \
285
+ "Batch ids: #{ids.join(', ')}"
286
+ end
287
+
288
+ # Duck-typed detection of PostgreSQL cardinality/uniqueness
289
+ # violations. The gem does not depend on the pg gem, and the error
290
+ # usually arrives wrapped in ActiveRecord::StatementInvalid, so match
291
+ # on class name and message across the error and its cause.
292
+ #
293
+ # @param error [StandardError]
294
+ # @return [Boolean]
295
+ def conflict_violation?(error)
296
+ [error, error.cause].compact.any? do |err|
297
+ err.class.name.to_s.match?(/CardinalityViolation|UniqueViolation/) ||
298
+ err.message.to_s.match?(/cannot affect row a second time|duplicate key value/)
299
+ end
300
+ end
301
+
144
302
  # Format a single entry as a SQL VALUES tuple.
145
303
  #
146
304
  # @param id [String] Unique identifier