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
@@ -5,6 +5,7 @@ require 'tempfile'
5
5
  require 'time'
6
6
  require 'msgpack'
7
7
  require 'woods/version'
8
+ require 'woods/atomic_file'
8
9
  require 'woods/storage/metadata_store'
9
10
  require 'woods/mcp/errors'
10
11
 
@@ -33,7 +34,7 @@ module Woods
33
34
  # atomically via +Tempfile+ + +File.rename+.
34
35
  #
35
36
  # @see Snapshotter::Vector companion class for vector stores
36
- module Metadata
37
+ module Metadata # rubocop:disable Metrics/ModuleLength
37
38
  # Magic string identifying a valid Woods Metadata Dump file.
38
39
  MAGIC = 'WMD1'
39
40
 
@@ -58,9 +59,28 @@ module Woods
58
59
  # @return [Woods::Storage::MetadataStore::InMemory]
59
60
  # @raise [Woods::MCP::UnsupportedArtifact] if magic is wrong or schema_version
60
61
  # exceeds {MAX_SUPPORTED_SCHEMA_VERSION}
61
- def self.load_or_empty(artifact, resolved_config: nil) # rubocop:disable Lint/UnusedMethodArgument
62
- dump_path = dump_file_path(artifact)
63
- return MetadataStore::InMemory.new unless dump_path&.exist?
62
+ def self.load_or_empty(artifact, resolved_config: nil)
63
+ load_dump_dir(artifact.latest_dump_path, resolved_config: resolved_config)
64
+ end
65
+
66
+ # Load from an EXPLICIT dump directory — the reload transaction's
67
+ # capture seam (M7). Candidates must hydrate from the dump identity
68
+ # captured before candidate construction, never from whatever
69
+ # +dumps/latest+ points at mid-build. By default this preserves
70
+ # {load_or_empty}'s boot semantics. Reload transactions pass
71
+ # +required: true+ so a missing promoted component fails closed rather
72
+ # than replacing a healthy store with an empty one.
73
+ #
74
+ # @param dump_dir [Pathname, String, nil] an explicit dump directory
75
+ # @param resolved_config [Object, nil] reserved for future validation
76
+ # @param required [Boolean] raise when the directory or dump file is absent
77
+ # @return [Woods::Storage::MetadataStore::InMemory]
78
+ # @raise [Woods::MCP::MissingArtifact] when +required+ and the dump is absent
79
+ # @raise [Woods::MCP::UnsupportedArtifact] if magic is wrong or schema_version
80
+ # exceeds {MAX_SUPPORTED_SCHEMA_VERSION}
81
+ def self.load_dump_dir(dump_dir, resolved_config: nil, required: false) # rubocop:disable Lint/UnusedMethodArgument
82
+ dump_path = dump_dir.nil? ? nil : Pathname.new(dump_dir.to_s).join(FILENAME)
83
+ return missing_dump_store(required, dump_path) unless dump_path&.exist?
64
84
 
65
85
  store = MetadataStore::InMemory.new
66
86
  File.open(dump_path.to_s, 'rb') do |io|
@@ -69,12 +89,31 @@ module Woods
69
89
  validate_header!(header, dump_path)
70
90
  header['record_count'].times do
71
91
  record = unpacker.read
92
+ raise_corrupt(dump_path, 'record is not a hash') unless record.is_a?(Hash)
93
+
72
94
  store.store(record['id'], record['metadata'])
73
95
  end
96
+ # A stream shorter (or more corrupt) than the header promises is an
97
+ # interrupted dump, not a programming error: it fails closed with the
98
+ # typed error the Vector twin raises for the same shape (M3/STO-4),
99
+ # so the MCP bootstrapper's one artifact-error branch covers both.
100
+ rescue EOFError, MessagePack::MalformedFormatError => e
101
+ raise_corrupt(dump_path, "truncated or malformed dump (#{e.class})")
74
102
  end
75
103
  store
76
104
  end
77
105
 
106
+ def self.missing_dump_store(required, dump_path)
107
+ return MetadataStore::InMemory.new unless required
108
+
109
+ missing = dump_path&.to_s || FILENAME
110
+ raise Woods::MCP::MissingArtifact.new(
111
+ "Required metadata dump component is missing: #{missing}",
112
+ details: { path: missing }
113
+ )
114
+ end
115
+ private_class_method :missing_dump_store
116
+
78
117
  # Write the metadata store to +dump_dir/metadata.msgpack+ atomically.
79
118
  #
80
119
  # Streams header then one packed hash per record — no full in-memory copy
@@ -89,7 +128,7 @@ module Woods
89
128
  # @raise [ArgumentError] if +dump_dir+ is not under +artifact.dumps_root+
90
129
  def self.dump(store, artifact, dump_dir, resolved_config: nil) # rubocop:disable Lint/UnusedMethodArgument
91
130
  validate_store!(store)
92
- validate_dump_dir!(artifact, dump_dir)
131
+ artifact.validate_dump_dir!(dump_dir, allow_root: true)
93
132
  target = Pathname.new(dump_dir.to_s).join(FILENAME)
94
133
  target.dirname.mkpath
95
134
  write_atomic(target, store)
@@ -106,6 +145,17 @@ module Woods
106
145
  latest.join(FILENAME)
107
146
  end
108
147
 
148
+ # @param path [Pathname] the dump being read
149
+ # @param reason [String] what was wrong with it
150
+ # @raise [Woods::MCP::UnsupportedArtifact] always
151
+ def raise_corrupt(path, reason)
152
+ raise Woods::MCP::UnsupportedArtifact.new(
153
+ "metadata.msgpack at #{path}: #{reason} — dump may have been interrupted " \
154
+ 'mid-write; re-run woods:embed',
155
+ details: { path: path.to_s, reason: reason }
156
+ )
157
+ end
158
+
109
159
  def validate_header!(header, path)
110
160
  unless header.is_a?(Hash) && header['magic'] == MAGIC
111
161
  raise Woods::MCP::UnsupportedArtifact,
@@ -114,6 +164,8 @@ module Woods
114
164
  "expected #{MAGIC.inspect}). The file may be corrupt or from an incompatible tool."
115
165
  end
116
166
 
167
+ validate_header_types!(header, path)
168
+
117
169
  version = header['schema_version']
118
170
  return if version <= MAX_SUPPORTED_SCHEMA_VERSION
119
171
 
@@ -123,12 +175,30 @@ module Woods
123
175
  'Upgrade the woods gem or re-run woods:embed to regenerate.'
124
176
  end
125
177
 
178
+ # Both integer fields are load-bearing: the version gates the read
179
+ # and the count drives the record loop. A header missing or
180
+ # mistyping either one used to reach a raw NoMethodError one frame
181
+ # later (STO-4).
182
+ #
183
+ # @param header [Hash]
184
+ # @param path [Pathname]
185
+ # @return [void]
186
+ # @raise [Woods::MCP::UnsupportedArtifact]
187
+ def validate_header_types!(header, path)
188
+ %w[schema_version record_count].each do |field|
189
+ value = header[field]
190
+ next if value.is_a?(Integer) && !value.negative?
191
+
192
+ raise_corrupt(path, "header #{field} is #{value.inspect}, expected a non-negative integer")
193
+ end
194
+ end
195
+
126
196
  # Write +store+ contents to +target+ atomically via a sibling Tempfile + rename.
127
197
  # Streams header then one record hash per entry.
128
198
  #
129
199
  # @param target [Pathname] final destination
130
200
  # @param store [#count, #each_entry] populated metadata store
131
- def write_atomic(target, store)
201
+ def write_atomic(target, store) # rubocop:disable Metrics/AbcSize
132
202
  tmp = Tempfile.new([FILENAME, '.tmp'], target.dirname.to_s)
133
203
  begin
134
204
  tmp.binmode
@@ -142,6 +212,9 @@ module Woods
142
212
  tmp.fsync
143
213
  tmp.close
144
214
  File.rename(tmp.path, target.to_s)
215
+ # Match AtomicFile: the directory entry itself must survive a
216
+ # crash after the rename, or the dump is not durable (M11).
217
+ Woods::AtomicFile.fsync_directory(target.dirname.to_s)
145
218
  rescue StandardError
146
219
  tmp.close
147
220
  tmp.unlink
@@ -155,16 +228,6 @@ module Woods
155
228
  raise InapplicableBackend,
156
229
  "backend #{store.class} is already durable — Snapshotter should not have been invoked"
157
230
  end
158
-
159
- def validate_dump_dir!(artifact, dump_dir)
160
- dump_path = Pathname.new(dump_dir.to_s).expand_path
161
- root = artifact.dumps_root.expand_path
162
-
163
- return if dump_path.to_s.start_with?("#{root}/") || dump_path == root
164
-
165
- raise ArgumentError,
166
- "dump_dir #{dump_path} is not under artifact.dumps_root #{root}"
167
- end
168
231
  end
169
232
  end
170
233
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'pathname'
4
4
  require 'tempfile'
5
+ require 'woods/atomic_file'
5
6
  require 'woods/storage/vector_store'
6
7
  require 'woods/mcp/errors'
7
8
  require 'woods/version'
@@ -43,16 +44,51 @@ module Woods
43
44
  # @raise [Woods::MCP::UnsupportedArtifact] if magic or schema_version is invalid
44
45
  # @raise [Woods::MCP::DimensionMismatch] if stored dimension ≠ +resolved_config.dimension+
45
46
  def self.load_or_empty(artifact, resolved_config: nil)
46
- dump_dir = artifact.latest_dump_path
47
- return VectorStore::InMemory.new if dump_dir.nil?
47
+ load_dump_dir(artifact.latest_dump_path, resolved_config: resolved_config)
48
+ end
49
+
50
+ # Load from an EXPLICIT dump directory — the reload transaction's
51
+ # capture seam (M7). Candidates must hydrate from the dump identity
52
+ # captured before candidate construction, never from whatever
53
+ # +dumps/latest+ points at mid-build; +load_or_empty+ re-reads the
54
+ # +latest+ pointer on every call and would mix halves from two dumps.
55
+ # By default this preserves {load_or_empty}'s boot semantics: a nil
56
+ # directory or a directory without the dump files yields an empty
57
+ # store. Reload transactions pass +required: true+ so a partially
58
+ # promoted dump fails closed instead of replacing a healthy store with
59
+ # an empty one.
60
+ #
61
+ # @param dump_dir [Pathname, String, nil] an explicit dump directory
62
+ # @param resolved_config [#dimension, nil] used for dimension validation
63
+ # @param required [Boolean] raise when the directory or either dump file is absent
64
+ # @return [Woods::Storage::VectorStore::InMemory]
65
+ # @raise [Woods::MCP::MissingArtifact] when +required+ and a dump component is absent
66
+ # @raise [Woods::MCP::UnsupportedArtifact] if magic or schema_version is invalid
67
+ # @raise [Woods::MCP::DimensionMismatch] if stored dimension ≠ +resolved_config.dimension+
68
+ def self.load_dump_dir(dump_dir, resolved_config: nil, required: false)
69
+ return missing_dump_store(required, []) if dump_dir.nil?
48
70
 
71
+ dump_dir = Pathname.new(dump_dir.to_s)
49
72
  bin_path = dump_dir.join('vectors.bin')
50
73
  idx_path = dump_dir.join('vectors.idx')
51
- return VectorStore::InMemory.new unless bin_path.exist? && idx_path.exist?
74
+ missing = [bin_path, idx_path].reject(&:exist?)
75
+ return missing_dump_store(required, missing) if missing.any?
52
76
 
53
77
  load_from(bin_path, idx_path, resolved_config)
54
78
  end
55
79
 
80
+ def self.missing_dump_store(required, paths)
81
+ return VectorStore::InMemory.new unless required
82
+
83
+ message = if paths.empty?
84
+ 'No promoted vector dump is available'
85
+ else
86
+ "Required vector dump component is missing: #{paths.join(', ')}"
87
+ end
88
+ raise Woods::MCP::MissingArtifact.new(message, details: { paths: paths.map(&:to_s) })
89
+ end
90
+ private_class_method :missing_dump_store
91
+
56
92
  # Writes +vectors.bin+ and +vectors.idx+ into +dump_dir+ atomically.
57
93
  #
58
94
  # @param store [#each_entry, #bulk_load] in-memory vector store adapter
@@ -64,7 +100,7 @@ module Woods
64
100
  # @raise [ArgumentError] if +dump_dir+ is not under +artifact.dumps_root+
65
101
  def self.dump(store, artifact, dump_dir, resolved_config: nil)
66
102
  validate_store!(store)
67
- validate_dump_dir!(artifact, Pathname.new(dump_dir.to_s))
103
+ artifact.validate_dump_dir!(dump_dir, allow_root: true)
68
104
  model_name = resolved_config.respond_to?(:model_name) ? resolved_config.model_name.to_s : ''
69
105
  entries = store.each_entry.to_a
70
106
  write_bin_and_idx(Pathname.new(dump_dir.to_s), entries, Woods::VERSION, model_name)
@@ -79,11 +115,25 @@ module Woods
79
115
  header, data_offset = parse_header(bin_data, bin_path)
80
116
  validate_magic!(header[:magic], bin_path)
81
117
  validate_schema_version!(header[:schema_version], bin_path)
82
- dim = resolved_config.respond_to?(:dimension) ? resolved_config.dimension : nil
83
- validate_dimension!(header[:dimension], dim, bin_path) if dim
84
- floats = bin_data.byteslice(data_offset, header[:vector_count] * header[:dimension] * 4)
85
- .unpack("e#{header[:vector_count] * header[:dimension]}")
86
- hydrate_store(parse_idx(idx_path), floats, header[:dimension])
118
+ validate_dimension_if_present!(header, resolved_config, bin_path)
119
+ floats = read_float_blob(bin_data, header, data_offset, bin_path)
120
+ ids = parse_idx(idx_path)
121
+ validate_idx_count!(ids.size, header[:vector_count], idx_path, bin_path)
122
+ hydrate_store(ids, floats, header[:dimension])
123
+ end
124
+
125
+ # A valid header over a truncated float payload used to unpack
126
+ # straight through: byteslice pads the missing tail with nil, so
127
+ # nil-floated vectors hydrated into the live store, crashed search
128
+ # with TypeError, and re-published as zeros on the next dump (M10).
129
+ # Unpack never invents data — the blob must be complete, and a
130
+ # truncated dump is an interrupted dump.
131
+ def read_float_blob(bin_data, header, data_offset, path)
132
+ float_count = header[:vector_count] * header[:dimension]
133
+ needed = float_count * 4
134
+ raise_truncated(path, bin_data.bytesize, data_offset + needed) if bin_data.bytesize - data_offset < needed
135
+
136
+ bin_data.byteslice(data_offset, needed).unpack("e#{float_count}")
87
137
  end
88
138
 
89
139
  def parse_header(bin_data, bin_path) # rubocop:disable Metrics/AbcSize
@@ -123,15 +173,48 @@ module Woods
123
173
  pairs = []
124
174
  pos = 0
125
175
  while pos < idx_data.bytesize
176
+ # Fail closed on truncation (M3), the same contract as the bin
177
+ # side's float-blob guard: a record that would read past EOF is
178
+ # an interrupted dump, and byteslice would otherwise pad it into
179
+ # a garbage (short) id that silently hydrates.
180
+ raise_truncated(idx_path, idx_data.bytesize, pos + 4) if idx_data.bytesize - pos < 4
126
181
  id_len = idx_data.byteslice(pos, 4).unpack1('L<')
182
+ record_end = pos + 4 + id_len + 8
183
+ raise_truncated(idx_path, idx_data.bytesize, record_end) if idx_data.bytesize < record_end
127
184
  pos += 4
128
- id = idx_data.byteslice(pos, id_len)
185
+ # The idx format stores ids as UTF-8 bytes (build_idx writes
186
+ # id.encode('UTF-8').b), but byteslice on a binread buffer
187
+ # yields ASCII-8BIT. Left untagged, a non-ASCII id is not eql?
188
+ # to its UTF-8 twin, so every hash lookup keyed on a hydrated
189
+ # id misses: the Indexer's checkpoint self-heal re-embedded the
190
+ # unit on every incremental run, and InMemory#store appended a
191
+ # duplicate live entry each time (B-080 / #192).
192
+ id = idx_data.byteslice(pos, id_len).force_encoding(Encoding::UTF_8)
129
193
  pos += id_len + 8 # skip the u64 offset (not needed for load)
130
194
  pairs << id
131
195
  end
132
196
  pairs
133
197
  end
134
198
 
199
+ # The idx record count and the bin header's vector_count describe
200
+ # the same dump; the idx maps ids onto the blob the header counts.
201
+ # When the halves disagree, hydration either crashed with a bare
202
+ # NoMethodError (idx longer: nil vectors reach InMemory#store) or
203
+ # silently served fewer vectors than the dump claims (idx shorter).
204
+ # Promotion ordering makes live exposure post-promotion-corruption-
205
+ # only, but the fail-closed-with-typed-error contract is the
206
+ # file's own standard — a mismatched dump is an interrupted or
207
+ # corrupted dump, never one to hydrate from.
208
+ def validate_idx_count!(idx_count, bin_count, idx_path, bin_path)
209
+ return if idx_count == bin_count
210
+
211
+ raise Woods::MCP::UnsupportedArtifact.new(
212
+ "#{idx_path}: vectors.idx holds #{idx_count} id records but #{bin_path} header declares " \
213
+ "#{bin_count} vectors — dump halves disagree; re-run woods:embed",
214
+ details: { path: idx_path.to_s, idx_count: idx_count, bin_vector_count: bin_count }
215
+ )
216
+ end
217
+
135
218
  def hydrate_store(ids, floats, dim)
136
219
  store = VectorStore::InMemory.new
137
220
  entries = ids.each_with_index.map do |id, idx|
@@ -160,6 +243,18 @@ module Woods
160
243
  )
161
244
  end
162
245
 
246
+ # An empty dump writes dimension=0 in the header (build_header has
247
+ # no vector to measure) — that's not a real mismatch against the
248
+ # provider's dimension, just the absence of data. Skip the check
249
+ # rather than raising DimensionMismatch on every embed of an
250
+ # empty payload.
251
+ def validate_dimension_if_present!(header, resolved_config, path)
252
+ return unless header[:vector_count].positive?
253
+
254
+ dim = resolved_config.respond_to?(:dimension) ? resolved_config.dimension : nil
255
+ validate_dimension!(header[:dimension], dim, path) if dim
256
+ end
257
+
163
258
  def validate_dimension!(stored, expected, path)
164
259
  return if stored == expected
165
260
 
@@ -210,26 +305,42 @@ module Woods
210
305
  tmp.fsync
211
306
  tmp.close
212
307
  File.rename(tmp.path, path.to_s)
308
+ # Match AtomicFile: the directory entry itself must survive a
309
+ # crash after the rename, or the dump is not durable (M11).
310
+ Woods::AtomicFile.fsync_directory(path.dirname.to_s)
213
311
  rescue StandardError
214
312
  tmp&.close
215
313
  tmp&.unlink
216
314
  raise
217
315
  end
218
316
 
317
+ # +respond_to?+ is the wrong question here: {VectorStore::Interface}
318
+ # *defines* both +#each_entry+ and +#bulk_load+ (as stubs — the
319
+ # former raises +NotImplementedError+, the latter delegates to
320
+ # +#store_batch+), and every adapter includes that module. Durable
321
+ # adapters (Pgvector, Qdrant) therefore answered +respond_to?+ with
322
+ # +true+ despite implementing neither, passed this guard, and hit
323
+ # the bare +NotImplementedError+ from +#each_entry+ instead of the
324
+ # typed error this method promises (B-108, see
325
+ # `Indexer#implements_own?`). Ask who *owns* the method instead.
219
326
  def validate_store!(store)
220
- return if store.respond_to?(:each_entry) && store.respond_to?(:bulk_load)
327
+ return if implements_own?(store, :each_entry) && store.respond_to?(:bulk_load)
221
328
 
222
329
  raise InapplicableBackend,
223
330
  "backend #{store.class} is already durable — Snapshotter should not have been invoked"
224
331
  end
225
332
 
226
- def validate_dump_dir!(artifact, dump_path)
227
- expanded = dump_path.expand_path
228
- root = artifact.dumps_root.expand_path
229
- return if expanded.to_s.start_with?("#{root}/") || expanded == root
333
+ # Does +object+ define +method_name+ itself, rather than inheriting
334
+ # {VectorStore::Interface}'s default stub?
335
+ #
336
+ # @param object [Object] the adapter under test
337
+ # @param method_name [Symbol]
338
+ # @return [Boolean]
339
+ def implements_own?(object, method_name)
340
+ return false unless object.respond_to?(method_name)
341
+ return true unless defined?(VectorStore::Interface)
230
342
 
231
- raise ArgumentError,
232
- "dump_dir #{expanded} is not under artifact.dumps_root #{root}"
343
+ object.method(method_name).owner != VectorStore::Interface
233
344
  end
234
345
  end
235
346
  end
@@ -9,15 +9,33 @@ module Woods
9
9
  # storage adapters to/from disk.
10
10
  #
11
11
  # Two adapters live here:
12
- # - {Snapshotter::Vector} — handles {VectorStore::InMemory} round-trips via +pack("e*")+.
13
- # - {Snapshotter::Metadata} handles {MetadataStore::InMemory} round-trips via MessagePack.
12
+ # - {Snapshotter::Vector} — handles {VectorStore::InMemory} round-trips via
13
+ # a raw +pack("e*")+ binary format (+vectors.bin+ / +vectors.idx+).
14
+ # - {Snapshotter::Metadata} — handles {MetadataStore::InMemory} round-trips
15
+ # via MessagePack (+metadata.msgpack+).
14
16
  #
15
17
  # Persistent backends (pgvector, Qdrant, SQLite) never touch the Snapshotter.
16
18
  # Passing one to {Snapshotter::Vector.dump} or {Snapshotter::Metadata.dump} raises
17
- # {InapplicableBackend} immediately.
19
+ # {InapplicableBackend} immediately — see each adapter's +validate_store!+.
18
20
  #
19
- # PR 2 ships stub implementations: +load_or_empty+ always returns an empty store
20
- # and +dump+ is a validated no-op. PR 3 wires in the real serialization paths.
21
+ # The two checks are deliberately different, because the two interfaces
22
+ # are:
23
+ # - {Snapshotter::Vector.validate_store!} checks *ownership* of
24
+ # +#each_entry+ rather than +respond_to?+ (B-108), because
25
+ # {VectorStore::Interface} defines it as a raising stub, so a durable
26
+ # adapter that merely includes the interface would otherwise pass.
27
+ # - {Snapshotter::Metadata.validate_store!} can use plain +respond_to?+
28
+ # only because {MetadataStore::Interface} defines neither +#each_entry+
29
+ # nor +#bulk_load+ — the seams exist on +InMemory+ alone. Adding either
30
+ # stub to that interface would silently convert this check into the
31
+ # B-108 bug, so the ownership check must move with it (STO-12).
32
+ #
33
+ # {Woods::Embedding::Indexer#persist_snapshot} is the write-side caller,
34
+ # invoked at the end of a successful {Woods::Embedding::Indexer#index_all}
35
+ # or +#index_incremental+ run when the configured vector store is
36
+ # in-memory. {Snapshotter::Vector.load_or_empty} is the read-side
37
+ # counterpart, used both by an incremental run (to hydrate before
38
+ # embedding) and by the MCP server at boot.
21
39
  module Snapshotter
22
40
  end
23
41
  end
@@ -52,6 +52,25 @@ module Woods
52
52
  raise NotImplementedError
53
53
  end
54
54
 
55
+ # Iterate over every stored id, yielding the raw embed id (which may
56
+ # carry a `#chunk_N` suffix). Vectors are NOT loaded.
57
+ #
58
+ # Reconciliation seam, and deliberately separate from {#each_entry}:
59
+ # the embed pipeline needs to know *what a durable store currently
60
+ # holds* so it can drop units the index no longer has, and pulling
61
+ # every vector back over the wire to answer a question about ids would
62
+ # be absurd. Durable adapters (pgvector, Qdrant) implement this one
63
+ # and not {#each_entry}.
64
+ #
65
+ # Adapters that do not implement it are simply not reconciled — see
66
+ # `Indexer#reconcilable?`.
67
+ #
68
+ # @yield [String] each stored id
69
+ # @return [Enumerator] when no block given
70
+ def each_id
71
+ raise NotImplementedError
72
+ end
73
+
55
74
  # Bulk-load pre-computed entries. Dual of {#each_entry} — the
56
75
  # Snapshotter hydrates a store by feeding this the dump contents.
57
76
  #
@@ -140,9 +159,6 @@ module Woods
140
159
  @tombstones = Set.new
141
160
  end
142
161
 
143
- # @return [Integer, nil] dimension of stored vectors, nil if empty
144
- attr_reader :dim
145
-
146
162
  # @see Interface#store
147
163
  def store(id, vector, metadata = {})
148
164
  @dim ||= vector.length
@@ -193,6 +209,17 @@ module Woods
193
209
  end
194
210
  end
195
211
 
212
+ # @see Interface#each_id
213
+ def each_id(&block)
214
+ return enum_for(:each_id) unless block
215
+
216
+ @ids.each_with_index do |id, idx|
217
+ next if @tombstones.include?(idx)
218
+
219
+ yield(id)
220
+ end
221
+ end
222
+
196
223
  # @see Interface#search
197
224
  def search(query_vector, limit: 10, filters: {})
198
225
  return [] if @dim.nil?
@@ -217,7 +244,7 @@ module Woods
217
244
  def delete_by_filter(filters)
218
245
  @ids.each_with_index do |id, idx|
219
246
  next if @tombstones.include?(idx)
220
- next unless filters.all? { |key, value| @metadata[idx][key] == value }
247
+ next unless filters.all? { |key, value| filter_match?(value, metadata_value(@metadata[idx], key)) }
221
248
 
222
249
  @tombstones << idx
223
250
  @id_to_index.delete(id)
@@ -237,6 +264,16 @@ module Woods
237
264
  filter_value.is_a?(Array) ? filter_value.include?(meta_value) : filter_value == meta_value
238
265
  end
239
266
 
267
+ # Indifferent metadata lookup: vector metadata is symbol-keyed on the
268
+ # live embed path (Indexer#store_vectors) but callers may filter with
269
+ # string keys, and pre-fix dumps hydrated string-keyed records — the
270
+ # key form must never decide whether a filter matches (#150 item 5).
271
+ def metadata_value(meta, key)
272
+ return nil unless meta
273
+
274
+ meta.fetch(key) { key.is_a?(Symbol) ? meta[key.to_s] : meta[key.to_sym] }
275
+ end
276
+
240
277
  # Append a new entry to the flat buffer.
241
278
  def append(id, vector, metadata)
242
279
  idx = @ids.size
@@ -246,8 +283,13 @@ module Woods
246
283
  @id_to_index[id] = idx
247
284
  end
248
285
 
249
- # Overwrite an existing entry in place. Tombstones the old slot's
250
- # deletion marker (if any) so the new vector is live again.
286
+ # Overwrite an existing entry in place.
287
+ #
288
+ # Only reachable via +#store+ for an +idx+ still present in
289
+ # +@id_to_index+ — every path that tombstones an index (+#delete+,
290
+ # +#delete_by_filter+) removes its id from +@id_to_index+ in the same
291
+ # operation, so an idx looked up here can never also be in
292
+ # +@tombstones+. There is nothing to un-tombstone.
251
293
  def overwrite(idx, vector, metadata)
252
294
  base = idx * @dim
253
295
  i = 0
@@ -256,7 +298,6 @@ module Woods
256
298
  i += 1
257
299
  end
258
300
  @metadata[idx] = metadata
259
- @tombstones.delete(idx)
260
301
  end
261
302
 
262
303
  # Walk every non-tombstoned index, apply filters, score survivors.
@@ -272,7 +313,7 @@ module Woods
272
313
  next
273
314
  end
274
315
  meta = @metadata[idx]
275
- unless filters.empty? || filters.all? { |k, v| filter_match?(v, meta[k]) }
316
+ unless filters.empty? || filters.all? { |k, v| filter_match?(v, metadata_value(meta, k)) }
276
317
  idx += 1
277
318
  next
278
319
  end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'base64'
4
+ require 'json'
5
+
6
+ module Woods
7
+ # Internal storage keys; public unit identifiers remain unchanged.
8
+ module StorageIdentity
9
+ PREFIX = '@woods-unit:'
10
+
11
+ def self.key(identifier, type)
12
+ PREFIX + Base64.urlsafe_encode64(JSON.generate([identifier.to_s, type.to_s]), padding: false)
13
+ end
14
+
15
+ def self.parts(key)
16
+ return unless key.to_s.start_with?(PREFIX)
17
+
18
+ value = JSON.parse(Base64.urlsafe_decode64(key.delete_prefix(PREFIX)))
19
+ value if value.is_a?(Array) && value.size == 2 && value.all?(String)
20
+ rescue ArgumentError, JSON::ParserError
21
+ nil
22
+ end
23
+
24
+ def self.identifier(key)
25
+ parts(key)&.first || key
26
+ end
27
+ end
28
+ end
data/lib/woods/tasks.rb CHANGED
@@ -30,6 +30,16 @@ module Woods
30
30
  builder = Builder.new(config)
31
31
  provider = builder.build_embedding_provider
32
32
 
33
+ # The Indexer gets the provider wrapped in the resilience stack
34
+ # (retry with backoff + per-instance circuit breaker) so a transient
35
+ # 429/5xx degrades the run instead of aborting it — on the :local /
36
+ # :shared_filesystem presets the dump only lands at end-of-run, so an
37
+ # unwrapped mid-run rate limit used to discard every embedding
38
+ # already paid for (#188 / B-076). The *raw* provider still feeds
39
+ # the tokenizer-calibration helpers and the ResolvedConfig probe,
40
+ # which key off the provider's concrete class.
41
+ resilient_provider = builder.build_resilient_embedding_provider(provider)
42
+
33
43
  # Wire the persistence-arc pieces (resolved_config, metadata_store,
34
44
  # dump_retention_count) so Indexer#persist_snapshot can write
35
45
  # woods.json, dump metadata, and honour the user's retention setting.
@@ -39,10 +49,13 @@ module Woods
39
49
  #
40
50
  # metadata_store and resolved_config are nil-safe — hosts that don't
41
51
  # configure metadata or that pre-date the persistence arc still work.
52
+ vector_store = builder.build_vector_store(dimensions: provider.dimensions)
53
+ verify_store_dimensions!(vector_store, provider)
54
+
42
55
  Embedding::Indexer.new(
43
- provider: provider,
56
+ provider: resilient_provider,
44
57
  text_preparer: builder.build_text_preparer(provider),
45
- vector_store: builder.build_vector_store,
58
+ vector_store: vector_store,
46
59
  metadata_store: config.metadata_store ? builder.build_metadata_store : nil,
47
60
  resolved_config: build_resolved_config(config, provider: provider),
48
61
  chunker: builder.build_chunker(provider),
@@ -51,6 +64,44 @@ module Woods
51
64
  )
52
65
  end
53
66
 
67
+ # Refuse to embed into a store built for a different vector width (#214).
68
+ #
69
+ # Durable stores are created once and reused: pgvector's `ensure_schema!`
70
+ # is `CREATE TABLE IF NOT EXISTS` and Qdrant treats its collection PUT as
71
+ # idempotent, so changing `embedding_model` leaves the old width in place.
72
+ # Without this check the run embedded everything first and only then hit a
73
+ # per-row server error — `PG::DataException: expected 384 dimensions, not
74
+ # 768`, or a Qdrant 400 — naming no remedy.
75
+ #
76
+ # The dump path has had this check since the persistence arc
77
+ # ({Storage::Snapshotter::Vector} validates the WVF1 header against
78
+ # `resolved_config.dimension`); this extends the same guarantee to the
79
+ # durable backends, which had none. It is a *pre-flight* check: adapters
80
+ # that cannot report a width, and stores that do not exist yet, return nil
81
+ # and are simply not checked.
82
+ #
83
+ # @param vector_store [Object] the configured store
84
+ # @param provider [#dimensions] the raw (unwrapped) embedding provider
85
+ # @raise [Woods::MCP::DimensionMismatch] when the widths disagree
86
+ # @return [void]
87
+ def verify_store_dimensions!(vector_store, provider)
88
+ return unless vector_store.respond_to?(:stored_dimensions)
89
+ return unless provider.respond_to?(:dimensions)
90
+
91
+ stored = vector_store.stored_dimensions
92
+ expected = provider.dimensions
93
+ return if stored.nil? || expected.nil? || stored == expected
94
+
95
+ require_relative 'mcp/errors'
96
+ raise Woods::MCP::DimensionMismatch.new(
97
+ "#{vector_store.class} holds #{stored}-dimension vectors but the configured provider " \
98
+ "produces #{expected}. Embedding into it would fail per-row. Re-index from scratch: " \
99
+ 'drop the vector store (woods_vectors table / Qdrant collection), then run ' \
100
+ 'woods:extract && woods:embed.',
101
+ details: { stored_dimension: stored, provider_dimension: expected, store: vector_store.class.to_s }
102
+ )
103
+ end
104
+
54
105
  # Build a ResolvedConfig snapshot from the live Woods::Configuration.
55
106
  # Returns nil if the configuration doesn't have enough to produce one
56
107
  # (pre-persistence-arc hosts) so the Indexer falls back to the legacy