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,22 +3,63 @@
3
3
  require 'json'
4
4
  require 'digest'
5
5
  require 'fileutils'
6
+ require 'set'
6
7
 
8
+ require_relative '../atomic_file'
9
+ require_relative '../storage_identity'
10
+ require_relative '../generation'
7
11
  require_relative '../extracted_unit'
8
12
  require_relative '../chunking/semantic_chunker'
13
+ require_relative '../util/uuid5'
9
14
 
10
15
  module Woods
16
+ # Standalone-require shim (same pattern as Console::Server and
17
+ # Storage::MetadataStore): ChunkSuffixCollision below inherits Woods::Error,
18
+ # which lib/woods.rb defines but an isolated require of this file does not.
19
+ class Error < StandardError; end unless defined?(Woods::Error)
20
+
11
21
  module Embedding
12
22
  # Orchestrates the indexing pipeline: reads extracted units, prepares text,
13
23
  # generates embeddings, and stores vectors. Supports full and incremental
14
24
  # modes with checkpoint-based resumability.
15
25
  #
16
- # When the vector store is an in-memory adapter (responds to +#each_entry+
17
- # and +#bulk_load+) and +output_dir+ is set, a successful {#index_all} run
18
- # also persists the stores to disk via the Snapshotter pair and atomically
19
- # flips the +dumps/latest+ pointer. Persistent backends (pgvector, Qdrant)
20
- # see zero behaviour change — no Snapshotter is invoked.
26
+ # When the vector store is an in-memory adapter (one that itself implements
27
+ # +#each_entry+, not merely inherits the interface stub) and +output_dir+ is
28
+ # set, a successful run — full or
29
+ # incremental — persists the stores to disk via the Snapshotter pair and
30
+ # atomically flips the +dumps/latest+ pointer. An incremental run hydrates
31
+ # the store from the previous dump first, so the dump it writes is
32
+ # cumulative. Persistent backends (pgvector, Qdrant) see zero behaviour
33
+ # change — no Snapshotter is invoked.
34
+ #
35
+ # For that in-memory path the dump is the *only* durable copy of a vector,
36
+ # which is why +checkpoint.json+ is written last, after the dump is
37
+ # promoted. See the invariant note on {#process_units}.
21
38
  class Indexer # rubocop:disable Metrics/ClassLength
39
+ # Raised when a unit's real identifier already matches the grammar
40
+ # {#collect_embed_items} uses to generate ids for split units
41
+ # ("identifier#chunk_N"). Five call sites elsewhere in the gem
42
+ # (retrieval/, {Retriever}, {MCP::Bootstrapper}) strip
43
+ # +/#chunk_\d+\z/+ unconditionally to recover a base identifier — so a
44
+ # genuine unit named e.g. "Foo#chunk_0" (a controller action literally
45
+ # named +chunk_0+) would be silently collapsed to "Foo" by those sites,
46
+ # and {#prune_identifier} would then delete its vector as a superseded
47
+ # chunk. The grammar is reserved rather than escaped: escaping would
48
+ # require touching those five strip sites, several of which this task
49
+ # is not permitted to change.
50
+ class ChunkSuffixCollision < Woods::Error
51
+ def initialize(identifier)
52
+ super(
53
+ "Unit identifier #{identifier.inspect} matches the embedding pipeline's " \
54
+ 'chunk-suffix grammar (/#chunk_\d+\z/), reserved for generated ids like ' \
55
+ '"identifier#chunk_0". Rename the underlying unit (the file, method, or ' \
56
+ 'route that produced this identifier) so it does not end in "#chunk_<N>" ' \
57
+ '— indexing cannot proceed safely otherwise, since retrieval strips that ' \
58
+ 'suffix unconditionally to recover the base identifier.'
59
+ )
60
+ end
61
+ end
62
+
22
63
  # @param chunker [Chunking::SemanticChunker, nil] Splits oversize units
23
64
  # into semantically coherent chunks before embedding. +nil+ disables
24
65
  # chunking — units go to the provider whole (useful in tests).
@@ -47,6 +88,9 @@ module Woods
47
88
  @metadata_store = metadata_store
48
89
  @resolved_config = resolved_config
49
90
  @dump_retention_count = dump_retention_count
91
+ @persisted_ids = {}
92
+ @durable_ids = nil
93
+ @checkpoint_misses = 0
50
94
  end
51
95
 
52
96
  # Index all extracted units (full mode). Returns stats hash.
@@ -58,12 +102,16 @@ module Woods
58
102
  #
59
103
  # @return [Hash] Stats with :processed, :skipped, :errors counts
60
104
  def index_all
61
- stats = process_units(load_units, incremental: false)
62
- persist_snapshot if persistable?
63
- stats
105
+ process_units(load_units, incremental: false)
64
106
  end
65
107
 
66
108
  # Index only changed units (incremental mode). Returns stats hash.
109
+ #
110
+ # When the vector store is an in-memory adapter the run first hydrates it
111
+ # from +dumps/latest+, so the dump written at the end of the run carries
112
+ # both the previously embedded vectors and this run's new ones. See
113
+ # {#process_units} for the invariant this upholds.
114
+ #
67
115
  # @return [Hash] Stats with :processed, :skipped, :errors counts
68
116
  def index_incremental
69
117
  process_units(load_units, incremental: true)
@@ -71,46 +119,470 @@ module Woods
71
119
 
72
120
  private
73
121
 
122
+ # Where this run's units are read from — the published generation's
123
+ # payload, or the output root for a flat index.
124
+ #
125
+ # Globbing the output root would walk `payloads/` and ingest every
126
+ # retained generation, so each unit would be embedded once per retained
127
+ # payload. It also picks up `dumps/`, which holds no unit JSON but is
128
+ # needlessly large to walk.
129
+ #
130
+ # @return [String]
131
+ def units_dir
132
+ Woods::Generation.new(output_dir: @output_dir).payload_dir.to_s
133
+ end
134
+
74
135
  def load_units
75
- Dir.glob(File.join(@output_dir, '**', '*.json')).filter_map do |path|
136
+ Dir.glob(File.join(units_dir, '**', '*.json')).filter_map do |path|
76
137
  next if File.basename(path) == 'checkpoint.json'
77
138
 
78
- data = JSON.parse(File.read(path))
139
+ # AtomicFile.read, not File.read: a bare read tags the bytes with the
140
+ # process's default external encoding (US-ASCII under LANG=C), so a
141
+ # single multibyte character in one unit raised EncodingError out of
142
+ # JSON.parse and aborted the whole embed run.
143
+ data = JSON.parse(AtomicFile.read(path))
79
144
  # Extraction output also contains index listings (_index.json arrays) and
80
145
  # summary files (manifest.json, dependency_graph.json, graph_analysis.json)
81
146
  # that live alongside per-unit JSON. Filter to the unit shape.
82
147
  data if data.is_a?(Hash) && data.key?('type') && data.key?('identifier')
83
- rescue JSON::ParserError
148
+ rescue JSON::ParserError, EncodingError => e
149
+ warn "[woods] skipping unreadable unit file #{path} (#{e.class}: #{e.message})"
84
150
  nil
85
151
  end
86
152
  end
87
153
 
154
+ # The invariant: **checkpoint.json never advances over a unit whose
155
+ # vector was not durably stored.** Two things uphold it here.
156
+ #
157
+ # 1. Ordering. For a store whose only durable copy is the dump
158
+ # (+persistable?+), the checkpoint is written *after*
159
+ # {#persist_snapshot} has written +dumps/<ts>/+ and flipped the
160
+ # +latest+ pointer — and only then. A raise anywhere before that
161
+ # (provider error, ENOSPC, interrupted dump) leaves the checkpoint
162
+ # exactly where the previous run left it, so the next run re-embeds
163
+ # this run's work. The interval checkpoints are suppressed on that
164
+ # path for the same reason: a dump is a whole-store snapshot, so
165
+ # there is no partial durability for them to record, and recording
166
+ # it anyway is the #148 data loss in miniature. Durable backends
167
+ # (pgvector, Qdrant) keep the interval saves — for them each
168
+ # +store_batch+ *is* the durable write, so a mid-run crash really
169
+ # has persisted those batches.
170
+ #
171
+ # 2. Trust, verified. {#checkpoint_satisfied?} honours a checkpoint hit
172
+ # only when the durable artifact actually holds a vector for that
173
+ # unit. A checkpoint that ran ahead of its dump — an older gem with
174
+ # this bug, an interrupted promote, a store swap — self-heals into a
175
+ # re-embed instead of stranding the unit forever.
88
176
  def process_units(units, incremental:)
177
+ prepare_run(incremental: incremental)
178
+ units = assign_storage_identities(units)
89
179
  checkpoint = incremental ? load_checkpoint : {}
90
180
  stats = { processed: 0, skipped: 0, errors: 0 }
91
- batch_count = 0
92
181
 
182
+ embed_batches(units, checkpoint, stats, incremental: incremental)
183
+
184
+ retire_legacy_identities
185
+ report_checkpoint_misses
186
+ vanished = incremental && persistable? ? drop_vanished_units : 0
187
+ persist_snapshot if persistable? && snapshot_worth_writing?(stats, vanished, incremental: incremental)
188
+ # Durable backends have no dump to rewrite, so staleness has to be
189
+ # removed from the store itself — on full runs too, since a full run
190
+ # against pgvector/Qdrant does not start from an empty store the way
191
+ # the in-memory path does (#211).
192
+ reconcile_durable_store if reconcilable?
193
+ save_checkpoint(checkpoint)
194
+
195
+ stats
196
+ end
197
+
198
+ # Unambiguous existing keys stay stable. A collision uses reversible typed keys.
199
+ def assign_storage_identities(units)
200
+ counts = units.group_by { |unit| unit['identifier'] }.transform_values(&:size)
201
+ units.map do |unit|
202
+ id = unit['identifier']
203
+ typed = StorageIdentity.key(id, unit['type'])
204
+ existing = known_storage_key?(typed)
205
+ next unit unless counts[id] > 1 || existing || id.start_with?(StorageIdentity::PREFIX)
206
+
207
+ unit.merge('storage_id' => typed)
208
+ end
209
+ end
210
+
211
+ def storage_id(unit)
212
+ unit['storage_id'] || unit['identifier']
213
+ end
214
+
215
+ def retire_legacy_identities
216
+ @current_identifiers.each do |key|
217
+ parts = StorageIdentity.parts(key)
218
+ next unless parts
219
+
220
+ legacy = parts.first
221
+ next if @current_identifiers.include?(legacy)
222
+
223
+ retire_legacy_key(legacy)
224
+ end
225
+ end
226
+
227
+ def known_storage_key?(key)
228
+ (@persisted_ids || {}).key?(key) || (@durable_ids || {}).key?(key)
229
+ end
230
+
231
+ def retire_legacy_key(legacy)
232
+ prune_identifier(legacy, []) if @persisted_ids&.key?(legacy)
233
+ delete_durable_identifiers([legacy]) if @durable_ids&.key?(legacy)
234
+ @persisted_ids&.delete(legacy)
235
+ @metadata_store.delete(legacy) if @metadata_store.respond_to?(:delete)
236
+ end
237
+
238
+ # Is there anything new for a dump to capture?
239
+ #
240
+ # A dump is a whole-store snapshot, so writing one for a run that embedded
241
+ # nothing rewrites and fsyncs every vector to produce byte-identical
242
+ # content — and, worse, rotates the retention window, so three no-op
243
+ # `woods:embed_incremental` runs evict every genuinely older dump in
244
+ # favour of copies of the same state.
245
+ #
246
+ # Safe because nothing else mutates the *vector* store on a zero-processed
247
+ # run: `prune_superseded_vectors` is reached only from `store_vectors`,
248
+ # which runs only for items that were actually embedded. A checkpoint
249
+ # self-heal counts as processed, so a run that re-embeds a stranded unit
250
+ # still dumps.
251
+ #
252
+ # But "nothing embedded" is not "nothing changed" (B-069). `persist_snapshot`
253
+ # writes the vector dump, the *metadata* dump and the config into one
254
+ # directory and promotes them together, so skipping it also freezes
255
+ # `metadata.msgpack`. A unit deleted from the index changes no unit's
256
+ # `source_hash`, so `processed` stays 0 — and pre-#171 that left both the
257
+ # stale vector *and* its metadata in place, which is what took the vector
258
+ # from inert (no metadata, so `ContextAssembler#find_batch` missed it) to
259
+ # retrievable by `codebase_retrieve`.
260
+ #
261
+ # Splitting the dump is not an option — a promoted directory holding fresh
262
+ # metadata and no vectors would hydrate empty on the next run. So instead
263
+ # the gate asks the fuller question: does the promoted dump describe units
264
+ # the index no longer has? `@persisted_ids` is what the dump holds (the
265
+ # hydration already read it, so this costs nothing) and
266
+ # `@current_identifiers` is what this run saw. Anything in the first and
267
+ # not the second is stale, and the dump has to be rewritten to drop it.
268
+ #
269
+ # Full runs always dump: they rebuild the store from scratch, so "nothing
270
+ # processed" there means the store is genuinely empty and the dump must
271
+ # say so rather than leave a stale one promoted.
272
+ #
273
+ # @return [Boolean]
274
+ def snapshot_worth_writing?(stats, vanished, incremental:)
275
+ return true unless incremental
276
+
277
+ stats[:processed].positive? || vanished.positive?
278
+ end
279
+
280
+ # Fraction of the persisted units the vanished-unit sweep may remove
281
+ # without an explicit override. Mirrors the 30% purge guard on the
282
+ # gem's other destructive sweeps (Obsidian VaultExporter, Unblocked
283
+ # Exporter — see their PURGE_GUARD_FRACTION).
284
+ VANISHED_PRUNE_MAX_RATIO = 0.3
285
+ private_constant :VANISHED_PRUNE_MAX_RATIO
286
+
287
+ # Delete vectors the promoted dump holds for units the index no longer has.
288
+ #
289
+ # Detecting staleness is not enough on its own: `hydrate_persisted_vectors`
290
+ # loads *every* vector in the dump back into the store, and
291
+ # `prune_superseded_vectors` only touches identifiers that were embedded
292
+ # this run — so without this the rewritten dump would faithfully reproduce
293
+ # the stale vector it was rewritten to drop.
294
+ #
295
+ # `@persisted_ids` is what the dump holds (hydration already read it, so
296
+ # this costs no IO) and `@current_identifiers` is what this run saw.
297
+ # Pruning with an empty fresh-id list removes every chunk of the unit.
298
+ #
299
+ # Guarded by {#vanished_prune_permitted?} (B-079 / #191) — a refused
300
+ # prune returns 0, which reads as "nothing vanished" to
301
+ # {#snapshot_worth_writing?}, so a run that also embedded nothing writes
302
+ # no dump and the retention window is not rotated over the good dumps.
303
+ # The warn precedes the deletes: if the prune raises partway, the
304
+ # operator still learns what it was doing.
305
+ #
306
+ # @return [Integer] how many units were dropped
307
+ def drop_vanished_units
308
+ return 0 if @persisted_ids.nil? || @persisted_ids.empty?
309
+
310
+ vanished = @persisted_ids.keys.reject { |identifier| @current_identifiers.include?(identifier) }
311
+ return 0 if vanished.empty?
312
+ return 0 unless vanished_prune_permitted?(vanished)
313
+
314
+ warn "[woods] dropping #{vanished.size} unit(s) from the vector index that the " \
315
+ 'extraction no longer holds; rewriting the dump.'
316
+ vanished.each do |identifier|
317
+ prune_identifier(identifier, [])
318
+ delete_unit_metadata(identifier)
319
+ end
320
+ vanished.size
321
+ end
322
+
323
+ def delete_unit_metadata(identifier)
324
+ @metadata_store.delete(identifier) if @metadata_store.respond_to?(:delete)
325
+ end
326
+
327
+ # Guard rail on the vanished-unit sweep (B-079 / #191).
328
+ #
329
+ # "Vanished" is computed as persisted-minus-current, so a run that
330
+ # loaded *nothing* — a mismatched WOODS_OUTPUT between shells, deleted
331
+ # extraction output, a glob failure — reads as "every unit vanished"
332
+ # and would prune the whole store; with retention 3, two more such runs
333
+ # then evict every dump that held real data. Two refusals, both
334
+ # overridable with WOODS_ALLOW_PURGE=1:
335
+ #
336
+ # - nothing loaded at all while the dump holds vectors: almost
337
+ # certainly a wrong output dir, never a real mass deletion;
338
+ # - vanished > {VANISHED_PRUNE_MAX_RATIO} of what the dump holds:
339
+ # suspicious enough to require an explicit override (or a full
340
+ # +woods:embed+, which rebuilds rather than prunes).
341
+ #
342
+ # Refusal never loses data: the stale vectors stay hydrated in the
343
+ # store, so any dump this run does write (for freshly embedded work)
344
+ # still carries them. Full runs never reach this guard —
345
+ # +drop_vanished_units+ runs only on the incremental path, and a full
346
+ # run's empty store is genuinely empty and must be dumped as such.
347
+ #
348
+ # @param vanished [Array<String>] identifiers about to be pruned
349
+ # @return [Boolean] true when the prune may proceed
350
+ def vanished_prune_permitted?(vanished)
351
+ return true if purge_override?
352
+
353
+ if @current_identifiers.empty?
354
+ warn_empty_load_refusal
355
+ return false
356
+ end
357
+
358
+ ratio = vanished.size.fdiv(@persisted_ids.size)
359
+ return true if ratio <= VANISHED_PRUNE_MAX_RATIO
360
+
361
+ warn "[woods] refusing to prune #{vanished.size} of #{@persisted_ids.size} persisted " \
362
+ "vector unit(s) (#{(ratio * 100).round}% > #{(VANISHED_PRUNE_MAX_RATIO * 100).round}% " \
363
+ 'purge guard). If this mass deletion is intentional, set WOODS_ALLOW_PURGE=1 ' \
364
+ 'or run a full woods:embed.'
365
+ false
366
+ end
367
+
368
+ def warn_empty_load_refusal
369
+ warn "[woods] nothing loaded from #{@output_dir} — likely a wrong or empty output dir — " \
370
+ "refusing to prune #{@persisted_ids.size} persisted vector unit(s) and leaving the " \
371
+ 'promoted dump untouched. Set WOODS_ALLOW_PURGE=1 to override.'
372
+ end
373
+
374
+ # @return [Boolean] true when WOODS_ALLOW_PURGE=1 bypasses the guard
375
+ def purge_override?
376
+ ENV.fetch('WOODS_ALLOW_PURGE', nil) == '1'
377
+ end
378
+
379
+ # Delete vectors a durable store holds for units the index no longer has.
380
+ #
381
+ # The dump-backed path gets staleness removal for free: the dump is
382
+ # rewritten from the live store each run, so a dropped unit simply stops
383
+ # being written. A durable backend has no such rewrite — rows in
384
+ # `woods_vectors` and points in Qdrant survive until something deletes
385
+ # them, which nothing did. A unit deleted from the codebase therefore
386
+ # stayed retrievable through `codebase_retrieve` indefinitely, *including
387
+ # after a full `woods:embed`*, because a full run against a durable store
388
+ # does not begin from an empty store (#211).
389
+ #
390
+ # Runs on full and incremental alike, and reconciles against
391
+ # `@current_identifiers` — every unit this run saw, embedded or skipped —
392
+ # so a skipped-because-unchanged unit is never mistaken for a vanished one.
393
+ #
394
+ # Guarded by {#durable_prune_permitted?}, the same 30%-plus-empty-load
395
+ # thresholds as the dump path (#191): the failure mode being defended
396
+ # against is identical, and worse here, since a durable delete has no
397
+ # dump to restore from.
398
+ #
399
+ # @return [Integer] how many units were dropped
400
+ def reconcile_durable_store
401
+ vanished = vanished_durable_identifiers
402
+ return 0 if vanished.empty?
403
+ return 0 unless durable_prune_permitted?(vanished)
404
+
405
+ delete_durable_identifiers(vanished)
406
+ end
407
+
408
+ # Identifiers the durable store holds that this run did not see.
409
+ #
410
+ # Ids Woods could not have written are excluded rather than treated as
411
+ # vanished (STO-2). A pgvector table or Qdrant collection may be shared
412
+ # with another writer, and a foreign row can never appear in
413
+ # +@current_identifiers+ — without this gate it would read as vanished
414
+ # and be deleted on every single run. The adapter's own read side is the
415
+ # first line of defence (Qdrant skips points with no +woods_identifier+
416
+ # payload); this is the belt-and-braces one, keyed on shapes Woods never
417
+ # mints as an identifier: canonical UUIDs and native integer point ids.
418
+ #
419
+ # @return [Array<String>]
420
+ def vanished_durable_identifiers
421
+ return [] if @durable_ids.nil?
422
+
423
+ @durable_ids.keys.reject do |identifier|
424
+ @current_identifiers.include?(identifier) || unattributable_id?(identifier)
425
+ end
426
+ end
427
+
428
+ # Could Woods have written this id? Identifiers come from extraction —
429
+ # class and file names — never a bare integer or a canonical UUID.
430
+ #
431
+ # @param identifier [Object] an id read back from the durable store
432
+ # @return [Boolean]
433
+ def unattributable_id?(identifier)
434
+ identifier.is_a?(Integer) || Util::UUID5.uuid?(identifier)
435
+ end
436
+
437
+ # Delete every stored id belonging to the given identifiers.
438
+ #
439
+ # The warn precedes the deletes so an operator still learns what was
440
+ # happening if one of them raises partway through.
441
+ #
442
+ # @param identifiers [Array<String>]
443
+ # @return [Integer] how many units were dropped
444
+ def delete_durable_identifiers(identifiers)
445
+ stale_ids = identifiers.flat_map { |identifier| @durable_ids[identifier] }
446
+ warn "[woods] deleting #{stale_ids.size} stale vector(s) for #{identifiers.size} unit(s) " \
447
+ "from #{@vector_store.class} that the extraction no longer holds."
448
+ stale_ids.each { |id| @vector_store.delete(id) }
449
+ identifiers.each do |identifier|
450
+ @durable_ids.delete(identifier)
451
+ delete_unit_metadata(identifier)
452
+ end
453
+ identifiers.size
454
+ end
455
+
456
+ # Guard rail on the durable-store sweep. Mirrors
457
+ # {#vanished_prune_permitted?}; the counts come from the store rather
458
+ # than from a dump, and a refusal here leaves the stale vectors in place
459
+ # (retrievable, but present) rather than risking a mass deletion that no
460
+ # dump can undo.
461
+ #
462
+ # @param vanished [Array<String>] identifiers about to be deleted
463
+ # @return [Boolean] true when the delete may proceed
464
+ def durable_prune_permitted?(vanished)
465
+ return true if purge_override?
466
+
467
+ if @current_identifiers.empty?
468
+ warn "[woods] nothing loaded from #{@output_dir} — likely a wrong or empty output dir — " \
469
+ "refusing to delete #{@durable_ids.size} unit(s) from #{@vector_store.class}. " \
470
+ 'Set WOODS_ALLOW_PURGE=1 to override.'
471
+ return false
472
+ end
473
+
474
+ ratio = vanished.size.fdiv(@durable_ids.size)
475
+ return true if ratio <= VANISHED_PRUNE_MAX_RATIO
476
+
477
+ warn "[woods] refusing to delete #{vanished.size} of #{@durable_ids.size} unit(s) from " \
478
+ "#{@vector_store.class} (#{(ratio * 100).round}% > " \
479
+ "#{(VANISHED_PRUNE_MAX_RATIO * 100).round}% purge guard). If this mass deletion is " \
480
+ 'intentional, set WOODS_ALLOW_PURGE=1.'
481
+ false
482
+ end
483
+
484
+ # Per-run state. An Indexer instance may be reused across runs, and
485
+ # neither the hydrated id index nor the miss counter may leak between
486
+ # them.
487
+ def prepare_run(incremental:)
488
+ @persisted_ids = {}
489
+ @current_identifiers = Set.new
490
+ @durable_ids = nil
491
+ @checkpoint_misses = 0
492
+ hydrate_persisted_vectors if incremental && persistable?
493
+ load_durable_store_ids if reconcilable?
494
+ end
495
+
496
+ # Read back what the durable store currently holds, as base identifiers.
497
+ #
498
+ # One enumeration serves both halves of #211:
499
+ #
500
+ # - {#checkpoint_satisfied?} can verify a checkpoint hit against the
501
+ # store instead of trusting it. Previously that verification existed
502
+ # only on the dump path, so switching `vector_store` from `:local` to
503
+ # pgvector/Qdrant left every unchanged unit stranded: the checkpoint
504
+ # said "done", the new store held nothing, and no run ever embedded it.
505
+ # - {#reconcile_durable_store} can delete what extraction no longer has.
506
+ #
507
+ # Stored ids may carry a `#chunk_N` suffix; the map keeps every raw id
508
+ # per base identifier so a delete can name each chunk exactly.
509
+ def load_durable_store_ids
510
+ @durable_ids = Hash.new { |hash, key| hash[key] = [] }
511
+ @vector_store.each_id { |id| @durable_ids[base_identifier(id)] << id }
512
+ rescue StandardError => e
513
+ # A store that cannot be enumerated must not take the embed run down
514
+ # with it. Reconciliation and the presence check both degrade to their
515
+ # pre-#211 behaviour (skip, and trust the checkpoint).
516
+ warn "[woods] could not read existing ids from #{@vector_store.class} " \
517
+ "(#{e.class}: #{e.message}) — skipping durable-store reconciliation this run."
518
+ @durable_ids = nil
519
+ end
520
+
521
+ # Strip the embedding-side chunk suffix to recover the unit identifier.
522
+ # `collect_embed_items` writes "User#chunk_0" for chunked units; the
523
+ # index only ever knows "User".
524
+ #
525
+ # A non-String id keeps its type: Qdrant's native integer point ids can
526
+ # only have come from another writer, and stringifying them here would
527
+ # disguise that shape from {#unattributable_id?}.
528
+ def base_identifier(id)
529
+ return id unless id.is_a?(String)
530
+
531
+ id.sub(/#chunk_\d+\z/, '')
532
+ end
533
+
534
+ # Can this run reconcile the vector store against extraction output?
535
+ #
536
+ # True for durable adapters that genuinely implement +#each_id+ *and*
537
+ # +#delete+ — as with {#persistable?}, +respond_to?+ is not the question,
538
+ # since the interface defines both methods for every adapter as raising
539
+ # stubs (B-108). Reconciliation ends in deletes, so an adapter that can
540
+ # be enumerated but not deleted from must not enter the path at all
541
+ # rather than raise +NotImplementedError+ mid-run (STO-11). The
542
+ # dump-backed path is excluded: it already reconciles via
543
+ # {#drop_vanished_units} plus the dump rewrite, and doing both would be
544
+ # redundant work on the same store.
545
+ def reconcilable?
546
+ !persistable? &&
547
+ implements_own?(@vector_store, :each_id) &&
548
+ implements_own?(@vector_store, :delete)
549
+ end
550
+
551
+ def embed_batches(units, checkpoint, stats, incremental:)
552
+ batch_count = 0
93
553
  units.each_slice(@batch_size) do |batch|
94
554
  process_batch(batch, checkpoint, stats, incremental: incremental)
95
555
  batch_count += 1
96
- save_checkpoint(checkpoint) if (batch_count % @checkpoint_interval).zero?
556
+ save_checkpoint(checkpoint) if interval_checkpoints? && (batch_count % @checkpoint_interval).zero?
97
557
  end
558
+ end
98
559
 
99
- # Always save final checkpoint
100
- save_checkpoint(checkpoint)
560
+ # Never let a disagreement between checkpoint.json and the dump pass
561
+ # silently — the re-embed is the safe outcome, but an operator seeing
562
+ # unexpected embedding cost deserves to know why.
563
+ def report_checkpoint_misses
564
+ return if @checkpoint_misses.zero?
101
565
 
102
- stats
566
+ warn "[woods] re-embedding #{@checkpoint_misses} unit(s) that checkpoint.json " \
567
+ 'marked as done but the vector store does not hold — the checkpoint had ' \
568
+ 'advanced past the durable artifact, or it describes a different store.'
569
+ end
570
+
571
+ # Interval checkpoints only make sense when each batch's +store_batch+
572
+ # was itself durable. See the invariant note on {#process_units}.
573
+ def interval_checkpoints?
574
+ !persistable?
103
575
  end
104
576
 
105
577
  def process_batch(batch, checkpoint, stats, incremental:)
106
578
  to_embed = batch.each_with_object([]) do |unit_data, items|
579
+ reject_chunk_suffix_collision!(unit_data['identifier'])
580
+
581
+ # Every unit passes through here, embedded or skipped, so this is the
582
+ # authoritative "what the index holds this run" set.
583
+ @current_identifiers << storage_id(unit_data)
107
584
  persist_unit_metadata(unit_data)
108
- # Incremental skip uses `source_hash`, which the extractor derives
109
- # from the unit's *source_code string only* (see ExtractedUnit#to_h
110
- # and Extractor#dump_units). It is NOT a hash of the serialized
111
- # unit_data JSON — so key ordering or whitespace in the _index.json
112
- # does not invalidate checkpoints across Ruby-minor upgrades.
113
- if incremental && checkpoint[unit_data['identifier']] == unit_data['source_hash']
585
+ if incremental && checkpoint_satisfied?(unit_data, checkpoint)
114
586
  stats[:skipped] += 1
115
587
  next
116
588
  end
@@ -120,6 +592,38 @@ module Woods
120
592
  embed_and_store(to_embed, checkpoint, stats)
121
593
  end
122
594
 
595
+ # May this unit's embedding be skipped?
596
+ #
597
+ # Incremental skip uses `source_hash`, which the extractor derives
598
+ # from the unit's *source_code string only* (see ExtractedUnit#to_h
599
+ # and Extractor#dump_units). It is NOT a hash of the serialized
600
+ # unit_data JSON — so key ordering or whitespace in the _index.json
601
+ # does not invalidate checkpoints across Ruby-minor upgrades.
602
+ #
603
+ # A matching hash is necessary but not sufficient: the vector must also
604
+ # actually exist. On the dump-backed path that means present in what we
605
+ # hydrated; on a durable backend, present in what the store reported.
606
+ #
607
+ # The durable half is #211's second bug. Before it, a checkpoint hit was
608
+ # trusted outright on pgvector/Qdrant — so pointing `vector_store` at a
609
+ # fresh durable store while keeping `output_dir` (the `:local` ->
610
+ # `:postgresql` migration the docs recommend) stranded every unchanged
611
+ # unit permanently: checkpoint.json said "embedded", the new store held
612
+ # nothing, and no subsequent incremental run ever disagreed.
613
+ def checkpoint_satisfied?(unit_data, checkpoint)
614
+ return false unless checkpoint[storage_id(unit_data)] == unit_data['source_hash']
615
+
616
+ known_ids = persistable? ? @persisted_ids : @durable_ids
617
+ # No durable view to check against (an adapter with no #each_id, or an
618
+ # enumeration that failed) — fall back to trusting the checkpoint.
619
+ return true if known_ids.nil?
620
+
621
+ return true if known_ids.key?(storage_id(unit_data))
622
+
623
+ @checkpoint_misses += 1
624
+ false
625
+ end
626
+
123
627
  # Persist a unit's metadata under its base identifier so retrieval can
124
628
  # resolve vector-search hits back to their unit data. Without this,
125
629
  # the metadata store is left empty at end of run — Snapshotter::Metadata
@@ -131,12 +635,24 @@ module Woods
131
635
  def persist_unit_metadata(unit_data)
132
636
  return unless @metadata_store
133
637
 
134
- @metadata_store.store(unit_data['identifier'], unit_data)
638
+ @metadata_store.store(storage_id(unit_data), unit_data)
639
+ end
640
+
641
+ # Refuse to index a unit whose real identifier already matches the
642
+ # chunk-suffix grammar. See {ChunkSuffixCollision}. Checked ahead of
643
+ # {#collect_embed_items} — an unchunked unit whose identifier already
644
+ # ends in "#chunk_0" would sail through with +embed_id == identifier+
645
+ # and never visibly generate the suffix itself, so the check can't
646
+ # live downstream of chunking.
647
+ def reject_chunk_suffix_collision!(identifier)
648
+ return unless identifier.to_s.match?(CHUNK_SUFFIX_PATTERN)
649
+
650
+ raise ChunkSuffixCollision, identifier
135
651
  end
136
652
 
137
653
  def collect_embed_items(unit_data, items)
138
654
  texts = prepare_texts(unit_data)
139
- identifier = unit_data['identifier']
655
+ identifier = storage_id(unit_data)
140
656
 
141
657
  texts.each_with_index do |text, idx|
142
658
  embed_id = texts.length > 1 ? "#{identifier}#chunk_#{idx}" : identifier
@@ -180,7 +696,7 @@ module Woods
180
696
  # tokenizes hotter than chars/token averages suggest, and Ollama
181
697
  # rejects over-budget input outright (see ollama/ollama#14186).
182
698
  def needs_chunking?(unit)
183
- budget_tokens = @provider.respond_to?(:max_input_tokens) ? @provider.max_input_tokens : nil
699
+ budget_tokens = safe_max_input_tokens
184
700
  return false if budget_tokens.nil?
185
701
  return false unless @text_preparer.respond_to?(:chars_per_token)
186
702
 
@@ -241,11 +757,13 @@ module Woods
241
757
  def store_vectors(items, vectors, checkpoint, stats)
242
758
  entries = items.each_with_index.map do |item, idx|
243
759
  { id: item[:id], vector: vectors[idx],
244
- metadata: { type: item[:unit_data]['type'], identifier: item[:identifier],
760
+ metadata: { type: item[:unit_data]['type'], identifier: item[:unit_data]['identifier'],
245
761
  file_path: item[:unit_data]['file_path'] } }
246
762
  end
247
763
 
248
764
  @vector_store.store_batch(entries)
765
+ prune_superseded_vectors(items)
766
+ prune_superseded_durable_vectors(items)
249
767
 
250
768
  items.each do |item|
251
769
  checkpoint[item[:identifier]] = item[:source_hash]
@@ -253,26 +771,246 @@ module Woods
253
771
  end
254
772
  end
255
773
 
774
+ # Suffix {#collect_embed_items} appends when a unit is split across
775
+ # several vectors. Mirrors the pattern in {Retriever},
776
+ # {Retrieval::ContextAssembler} and {MCP::Bootstrapper}.
777
+ CHUNK_SUFFIX_PATTERN = /#chunk_\d+\z/
778
+ private_constant :CHUNK_SUFFIX_PATTERN
779
+
780
+ # Hydrate the in-memory vector store from +dumps/latest+ so the dump
781
+ # this run writes is the previous dump *plus* this run's changes.
782
+ #
783
+ # Hydration is lossless with respect to what the artifact can hold: the
784
+ # WVF1 format stores id + float blob only, so the empty per-entry
785
+ # metadata a load produces is exactly what a dump round-trips to either
786
+ # way (woods-mcp back-fills it from metadata.msgpack at boot — see
787
+ # MCP::Bootstrapper.populate_vector_metadata).
788
+ #
789
+ # A failure to read the dump (corrupt file, dimension mismatch after a
790
+ # model switch) is not fatal: an empty +@persisted_ids+ means no
791
+ # checkpoint hit can be honoured, so every unit is re-embedded and the
792
+ # dump this run writes is complete. That is a full re-embed's cost, which
793
+ # is the documented remedy for both of those conditions anyway — so warn
794
+ # and carry on rather than stranding the host with no way forward.
795
+ def hydrate_persisted_vectors
796
+ require_relative '../index_artifact'
797
+ require_relative '../storage/snapshotter'
798
+
799
+ loaded = Storage::Snapshotter::Vector.load_or_empty(
800
+ IndexArtifact.new(@output_dir), resolved_config: @resolved_config
801
+ )
802
+ entries = loaded.each_entry.map { |id, vector, metadata| { id: id, vector: vector, metadata: metadata || {} } }
803
+ @vector_store.clear! if @vector_store.respond_to?(:clear!)
804
+ @vector_store.bulk_load(entries)
805
+ @persisted_ids = index_ids_by_identifier(entries)
806
+ rescue StandardError => e
807
+ warn "[woods] could not hydrate vectors from the latest dump (#{e.class}: #{e.message}); " \
808
+ 're-embedding every unit so the dump this run writes is complete.'
809
+ @vector_store.clear! if @vector_store.respond_to?(:clear!)
810
+ @persisted_ids = {}
811
+ end
812
+
813
+ # base identifier => the vector ids the dump holds for it.
814
+ def index_ids_by_identifier(entries)
815
+ entries.each_with_object({}) do |entry, index|
816
+ identifier = entry[:id].to_s.sub(CHUNK_SUFFIX_PATTERN, '')
817
+ (index[identifier] ||= []) << entry[:id]
818
+ end
819
+ end
820
+
821
+ # Drop vector ids the previous dump held for a re-embedded identifier
822
+ # that this run did not rewrite. A unit that used to split into five
823
+ # chunks and now splits into three would otherwise leave +#chunk_3+ and
824
+ # +#chunk_4+ in the hydrated store, and the dump would serve chunks
825
+ # whose source no longer exists.
826
+ #
827
+ # Scoped to the hydrated ids on purpose: a durable backend's own
828
+ # staleness is its own write path's business, and this method must not
829
+ # start issuing deletes against pgvector or Qdrant.
830
+ def prune_superseded_vectors(items)
831
+ return if @persisted_ids.empty?
832
+ return unless implements_own?(@vector_store, :delete)
833
+
834
+ items.group_by { |item| item[:identifier] }.each do |identifier, group|
835
+ prune_identifier(identifier, group.map { |item| item[:id] })
836
+ end
837
+ end
838
+
839
+ def prune_identifier(identifier, fresh_ids)
840
+ previous = @persisted_ids[identifier]
841
+ return unless previous
842
+
843
+ (previous - fresh_ids).each { |id| @vector_store.delete(id) }
844
+ @persisted_ids[identifier] = fresh_ids
845
+ end
846
+
847
+ # Durable stores do not get rewritten from a complete dump after each
848
+ # run. If a still-present unit is re-embedded with fewer chunks, remove
849
+ # the old chunk rows/points that no current embed item rewrote.
850
+ def prune_superseded_durable_vectors(items)
851
+ return if @durable_ids.nil?
852
+ return unless implements_own?(@vector_store, :delete)
853
+
854
+ items.group_by { |item| item[:identifier] }.each do |identifier, group|
855
+ prune_durable_identifier(identifier, group.map { |item| item[:id] })
856
+ end
857
+ end
858
+
859
+ def prune_durable_identifier(identifier, fresh_ids)
860
+ previous = @durable_ids[identifier]
861
+ return unless previous
862
+
863
+ (previous - fresh_ids).each { |id| @vector_store.delete(id) }
864
+ @durable_ids[identifier] = fresh_ids
865
+ end
866
+
867
+ # A checkpoint that cannot be parsed *or read* degrades to "no
868
+ # checkpoint" — every unit reads as changed and is re-embedded, the same
869
+ # self-healing outcome as a corrupt checkpoint. AtomicFile.read keeps a
870
+ # non-ASCII identifier from raising EncodingError here in the first place.
256
871
  def load_checkpoint
257
872
  path = File.join(@output_dir, 'checkpoint.json')
258
873
  return {} unless File.exist?(path)
259
874
 
260
- JSON.parse(File.read(path))
261
- rescue JSON::ParserError
875
+ checkpoint_hashes(JSON.parse(AtomicFile.read(path)))
876
+ rescue JSON::ParserError, EncodingError
262
877
  {}
263
878
  end
264
879
 
880
+ # AtomicFile.write, not File.write: a crash mid-write must leave the old
881
+ # checkpoint intact, never a torn partial — a truncated checkpoint reads
882
+ # as "no checkpoint" and silently re-embeds everything.
265
883
  def save_checkpoint(checkpoint)
266
- File.write(File.join(@output_dir, 'checkpoint.json'), JSON.generate(checkpoint))
884
+ AtomicFile.write(File.join(@output_dir, 'checkpoint.json'), JSON.generate(checkpoint_payload(checkpoint)))
267
885
  end
268
886
 
269
- # Returns true when the vector store is an in-memory adapter that supports
270
- # the persistence seam (+#each_entry+ / +#bulk_load+) and output_dir is set.
271
- # Persistent backends (pgvector, Qdrant) never respond to +#each_entry+.
887
+ # Schema of the on-disk checkpoint payload when {#resolved_config} is
888
+ # tracked. Bump only alongside a reader change in {#checkpoint_hashes}.
889
+ CHECKPOINT_SCHEMA_VERSION = 1
890
+ private_constant :CHECKPOINT_SCHEMA_VERSION
891
+
892
+ # The provider/model/dimension triple checkpoint.json is stamped with,
893
+ # or +nil+ when this indexer was built without a +resolved_config+ (no
894
+ # identity to stamp or compare against — see {#checkpoint_payload} and
895
+ # {#checkpoint_hashes}, both of which treat +nil+ as "skip identity
896
+ # tracking entirely" for full backward compatibility with callers that
897
+ # never pass one).
898
+ #
899
+ # Reads {ResolvedConfig#to_snapshot_json} rather than calling
900
+ # +#embedding_provider+/+#dimension+ directly so a test double only
901
+ # needs to stub the one method the WVF1 header path already requires.
902
+ #
903
+ # @return [Hash, nil]
904
+ def current_checkpoint_identity
905
+ return nil unless @resolved_config
906
+
907
+ provider = @resolved_config.to_snapshot_json['embedding_provider'] || {}
908
+ provider.transform_keys(&:to_s).slice('class', 'model', 'dimension')
909
+ end
910
+
911
+ # Wrap the flat identifier=>source_hash map with its identity stamp for
912
+ # writing, or leave it flat when this run tracks no identity.
913
+ def checkpoint_payload(checkpoint)
914
+ identity = current_checkpoint_identity
915
+ return checkpoint if identity.nil?
916
+
917
+ { 'schema_version' => CHECKPOINT_SCHEMA_VERSION, 'identity' => identity, 'hashes' => checkpoint }
918
+ end
919
+
920
+ # Recover the flat identifier=>source_hash map {#checkpoint_satisfied?}
921
+ # consumes from whichever on-disk shape was parsed. Two shapes:
922
+ #
923
+ # - versioned (carries a top-level "hashes" key): written by this gem
924
+ # version, stamped with the provider/model/dimension identity that
925
+ # produced it (see #checkpoint_payload). A stamped identity that
926
+ # disagrees with {#current_checkpoint_identity} — a same-dimension
927
+ # model switch, the P1 finding this exists to close — means nothing
928
+ # here can say which individual hits are still good, so the *whole*
929
+ # checkpoint is discarded rather than trusted per-unit.
930
+ # - flat (every checkpoint written before this gem version): carries no
931
+ # identity at all. When this run tracks identity (a resolved_config
932
+ # was given), "no identity recorded" is indistinguishable from "the
933
+ # identity that produced this changed" — so it is discarded the same
934
+ # way: one full re-embed, after which every checkpoint this gem
935
+ # writes is stamped and can be trusted again. When this run has no
936
+ # resolved_config either there is nothing to compare against, and the
937
+ # flat map is trusted exactly as every prior gem version did.
938
+ def checkpoint_hashes(data)
939
+ return data unless data.is_a?(Hash)
940
+
941
+ current = current_checkpoint_identity
942
+ return checkpoint_hashes_versioned(data, current) if data.key?('hashes')
943
+ return data if current.nil?
944
+
945
+ warn '[woods] checkpoint.json predates embedding-identity tracking and cannot be ' \
946
+ 'verified against the current provider/model — discarding it and re-embedding ' \
947
+ 'every unit once so future checkpoints are stamped and can be trusted safely.'
948
+ {}
949
+ end
950
+
951
+ def checkpoint_hashes_versioned(data, current)
952
+ stamped = data['identity']
953
+ return data['hashes'] || {} if current.nil? || stamped == current
954
+
955
+ warn '[woods] checkpoint.json was stamped for a different embedding identity ' \
956
+ "(#{stamped.inspect} vs current #{current.inspect}) — the provider or model " \
957
+ 'changed since the last run. Discarding the checkpoint and re-embedding every ' \
958
+ 'unit so no stale-model vector survives.'
959
+ {}
960
+ end
961
+
962
+ # Returns true when the vector store can actually be dumped to
963
+ # +output_dir+ — that is, when it genuinely implements the persistence
964
+ # seam (+#each_entry+).
965
+ #
966
+ # +respond_to?+ is the wrong question, and asking it was a live crash.
967
+ # {Storage::VectorStore::Interface} *defines* +#each_entry+ (as a
968
+ # +NotImplementedError+ raise) and +#bulk_load+ (delegating to
969
+ # +#store_batch+), and every adapter includes the module — so pgvector
970
+ # and Qdrant answered +respond_to?(:each_entry)+ with +true+ despite
971
+ # implementing neither. +persistable?+ said yes, and {#persist_snapshot}
972
+ # drove +Snapshotter::Vector.dump+ into the interface's raise at the very
973
+ # end of an otherwise-successful run, discarding a whole embed pass
974
+ # (every vector already paid for) with a bare +NotImplementedError+.
975
+ #
976
+ # Ask who *owns* the method instead: an adapter that merely inherited the
977
+ # interface's stub has not implemented it. +bulk_load+ is deliberately not
978
+ # part of the test — its interface default is a working implementation, so
979
+ # it discriminates nothing.
272
980
  def persistable?
273
- @output_dir &&
274
- @vector_store.respond_to?(:each_entry) &&
275
- @vector_store.respond_to?(:bulk_load)
981
+ return false unless @output_dir
982
+
983
+ implements_own?(@vector_store, :each_entry)
984
+ end
985
+
986
+ # @provider's input-token budget, or nil when it has none.
987
+ # `respond_to?` alone is the wrong guard here: {Embedding::Provider::Interface}
988
+ # *defines* +max_input_tokens+ as a +NotImplementedError+ stub, so a
989
+ # provider that merely includes the interface without overriding it
990
+ # still answers +respond_to?+ with +true+ (B-108) and raises when
991
+ # called. A provider with no such method at all still needs the
992
+ # +respond_to?+ guard to avoid a bare +NoMethodError+.
993
+ #
994
+ # @return [Integer, nil]
995
+ def safe_max_input_tokens
996
+ return nil unless @provider.respond_to?(:max_input_tokens)
997
+
998
+ @provider.max_input_tokens
999
+ rescue NotImplementedError
1000
+ nil
1001
+ end
1002
+
1003
+ # Does +object+ define +method_name+ itself, rather than inheriting the
1004
+ # vector-store interface's default stub?
1005
+ #
1006
+ # @param object [Object] the adapter under test
1007
+ # @param method_name [Symbol]
1008
+ # @return [Boolean]
1009
+ def implements_own?(object, method_name)
1010
+ return false unless object.respond_to?(method_name)
1011
+ return true unless defined?(Storage::VectorStore::Interface)
1012
+
1013
+ object.method(method_name).owner != Storage::VectorStore::Interface
276
1014
  end
277
1015
 
278
1016
  # Persist stores to a timestamped dump directory, write +woods.json+,
@@ -282,35 +1020,89 @@ module Woods
282
1020
  require_relative '../storage/snapshotter'
283
1021
 
284
1022
  artifact = IndexArtifact.new(@output_dir)
285
- dump_dir = artifact.new_dump_dir
1023
+ dump_dir = unique_dump_dir(artifact)
286
1024
 
287
- Storage::Snapshotter::Vector.dump(@vector_store, artifact, dump_dir)
1025
+ # Pass resolved_config: the WVF1 header carries a model_name field, and
1026
+ # omitting it wrote an empty string into every dump — so the artifact
1027
+ # could not say which model produced it, and any check that wants to
1028
+ # compare a dump against the configured provider has nothing to read.
1029
+ Storage::Snapshotter::Vector.dump(@vector_store, artifact, dump_dir, resolved_config: @resolved_config)
288
1030
 
289
1031
  if @metadata_store.respond_to?(:each_entry) && @metadata_store.respond_to?(:bulk_load)
290
1032
  Storage::Snapshotter::Metadata.dump(@metadata_store, artifact, dump_dir)
291
1033
  end
292
1034
 
293
- artifact.write_config(@resolved_config) if @resolved_config
1035
+ # Written INSIDE the dump directory, as part of the dump, so #promote
1036
+ # below is the single commit point for the vectors/metadata AND the
1037
+ # config that describes them — a crash between this write and
1038
+ # #promote leaves the previous promoted dump (and its own config)
1039
+ # untouched. See IndexArtifact#read_config, which prefers this copy.
1040
+ artifact.write_dump_config(dump_dir, @resolved_config) if @resolved_config
294
1041
 
295
1042
  artifact.promote(dump_dir)
296
1043
 
1044
+ # Written AFTER promote so the commit point stays the promotion
1045
+ # above — this root copy is for anything that reads
1046
+ # output_dir/woods.json directly instead of through
1047
+ # IndexArtifact#read_config.
1048
+ artifact.write_config(@resolved_config) if @resolved_config
1049
+
297
1050
  prune_old_dumps(artifact)
298
1051
  end
299
1052
 
1053
+ # Seconds of timestamp to walk forward looking for a free dump directory
1054
+ # name before giving up and letting Errno::EEXIST out.
1055
+ DUMP_DIR_ATTEMPTS = 60
1056
+ private_constant :DUMP_DIR_ATTEMPTS
1057
+
1058
+ # Mint a dump directory, stepping the timestamp forward on a collision.
1059
+ #
1060
+ # Dump directory names have one-second resolution and {IndexArtifact}
1061
+ # deliberately refuses to reuse one (an explicit +now:+ collision is a
1062
+ # caller error). Now that *every* run dumps — and incremental runs carry
1063
+ # no cooldown, unlike the full runs PipelineGuard rate-limits — two runs
1064
+ # inside one second is reachable on a small index. Walking the name
1065
+ # forward keeps chronological and lexicographic order in agreement
1066
+ # (…28Z < …29Z), which is what prune_old_dumps sorts on, and beats
1067
+ # discarding embedding work that has already been done and paid for.
1068
+ def unique_dump_dir(artifact)
1069
+ now = Time.now.utc
1070
+ attempts = 0
1071
+ begin
1072
+ artifact.new_dump_dir(now: now)
1073
+ rescue Errno::EEXIST
1074
+ attempts += 1
1075
+ raise if attempts >= DUMP_DIR_ATTEMPTS
1076
+
1077
+ now += 1
1078
+ retry
1079
+ end
1080
+ end
1081
+
300
1082
  # Remove old dump directories beyond the retention window.
301
1083
  #
302
1084
  # Keeps the +@dump_retention_count+ most-recently-created directories
303
1085
  # (sorted by name, which is a UTC timestamp so lexicographic order equals
304
- # chronological order). The current +latest+ directory is always kept.
1086
+ # chronological order). The current +latest+ directory is always kept —
1087
+ # true by construction: it is filtered out of the prune candidates
1088
+ # below, not merely assumed to sort last. A backward wall-clock step
1089
+ # (NTP correction, a stubbed clock in a spec) can mint a new dump
1090
+ # directory whose name sorts *before* older ones, which used to put the
1091
+ # dump #persist_snapshot had just promoted at the front of the "oldest
1092
+ # first" prune list — deleting it out from under the +latest+ pointer
1093
+ # that was made to point at it moments earlier.
305
1094
  def prune_old_dumps(artifact)
306
1095
  return if @dump_retention_count.nil? || @dump_retention_count <= 0
307
1096
 
308
1097
  dumps_root = artifact.dumps_root
309
1098
  return unless dumps_root.exist?
310
1099
 
1100
+ latest = artifact.latest_dump_path&.to_s
311
1101
  dirs = sorted_dump_dirs(dumps_root)
312
1102
  excess = dirs.length - @dump_retention_count
313
- dirs.first(excess).each { |dir| FileUtils.rm_rf(dir) } if excess.positive?
1103
+ return unless excess.positive?
1104
+
1105
+ (dirs.first(excess) - [latest]).each { |dir| FileUtils.rm_rf(dir) }
314
1106
  end
315
1107
 
316
1108
  def sorted_dump_dirs(dumps_root)