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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +2035 -0
- data/CONTRIBUTING.md +253 -87
- data/README.md +161 -513
- data/SECURITY.md +92 -0
- data/assets/woods-wordmark-white-with-bg.png +0 -0
- data/docs/AGENT_GUIDE.md +204 -0
- data/docs/AGENT_SETUP.md +205 -0
- data/docs/BACKEND_MATRIX.md +470 -0
- data/docs/CONFIGURATION_REFERENCE.md +655 -0
- data/docs/CONSOLE_MCP_SETUP.md +829 -0
- data/docs/DOCKER_SETUP.md +454 -0
- data/docs/EMBEDDING_MODELS.md +136 -0
- data/docs/EVALUATION.md +91 -0
- data/docs/EXTRACTOR_REFERENCE.md +765 -0
- data/docs/FAQ.md +544 -0
- data/docs/GETTING_STARTED.md +183 -0
- data/docs/INCREMENTAL_EXTRACTION.md +455 -0
- data/docs/INTERNALS.md +418 -0
- data/docs/MCP_HTTP_TRANSPORT.md +144 -0
- data/docs/MCP_SERVERS.md +231 -0
- data/docs/MCP_TOOL_COOKBOOK.md +987 -0
- data/docs/MCP_WORKTREE_SETUP.md +127 -0
- data/docs/NOTION_INTEGRATION.md +283 -0
- data/docs/OBSIDIAN_INTEGRATION.md +170 -0
- data/docs/PUBLISHED_INDEX.md +213 -0
- data/docs/README.md +94 -0
- data/docs/RETRIEVAL_GUIDE.md +267 -0
- data/docs/TOKEN_BENCHMARK.md +68 -0
- data/docs/TROUBLESHOOTING.md +841 -0
- data/docs/UNBLOCKED_INTEGRATION.md +279 -0
- data/docs/UPGRADING_TO_2.md +321 -0
- data/docs/WATCH_DAEMON.md +667 -0
- data/docs/WHY_WOODS.md +219 -0
- data/exe/woods-console +40 -4
- data/exe/woods-console-mcp +21 -35
- data/exe/woods-mcp +20 -7
- data/exe/woods-mcp-http +80 -11
- data/exe/woods-mcp-start +57 -52
- data/lib/generators/woods/install_generator.rb +6 -5
- data/lib/generators/woods/pgvector_generator.rb +6 -3
- data/lib/generators/woods/templates/add_pgvector_to_woods.rb.erb +29 -9
- data/lib/generators/woods/templates/create_woods_tables.rb.erb +5 -1
- data/lib/generators/woods/templates/woods.rb.tt +49 -28
- data/lib/tasks/woods.rake +622 -168
- data/lib/tasks/woods_checks.rake +107 -0
- data/lib/tasks/woods_evaluation.rake +164 -80
- data/lib/woods/ast/call_site_extractor.rb +6 -15
- data/lib/woods/ast/method_extractor.rb +19 -9
- data/lib/woods/ast/parser.rb +54 -8
- data/lib/woods/atomic_file.rb +171 -2
- data/lib/woods/builder.rb +310 -22
- data/lib/woods/cache/cache_middleware.rb +7 -2
- data/lib/woods/cache/cache_store.rb +9 -1
- data/lib/woods/cache/solid_cache_store.rb +6 -4
- data/lib/woods/change_set.rb +88 -0
- data/lib/woods/checks/generation_resolution.rb +34 -0
- data/lib/woods/checks/moved_messages.rb +186 -0
- data/lib/woods/chunking/semantic_chunker.rb +160 -18
- data/lib/woods/console/audit_logger.rb +12 -3
- data/lib/woods/console/bridge_protocol.rb +3 -16
- data/lib/woods/console/connection_manager.rb +51 -136
- data/lib/woods/console/dispatch_pipeline.rb +42 -12
- data/lib/woods/console/embedded_executor.rb +806 -149
- data/lib/woods/console/eval_guard.rb +27 -20
- data/lib/woods/console/input_contract.rb +78 -0
- data/lib/woods/console/model_validator.rb +29 -1
- data/lib/woods/console/rack_middleware.rb +65 -42
- data/lib/woods/console/redactor.rb +26 -8
- data/lib/woods/console/safe_context.rb +58 -10
- data/lib/woods/console/scope_predicate_parser.rb +41 -0
- data/lib/woods/console/server.rb +119 -247
- data/lib/woods/console/sql_noise_stripper.rb +125 -16
- data/lib/woods/console/sql_table_scanner.rb +82 -22
- data/lib/woods/console/sql_validator.rb +459 -29
- data/lib/woods/console/table_gate.rb +2 -2
- data/lib/woods/console/tool_specs.rb +463 -90
- data/lib/woods/console/tools/tier1.rb +1 -5
- data/lib/woods/console/tools/tier4.rb +18 -9
- data/lib/woods/coordination/lock_heartbeat.rb +103 -0
- data/lib/woods/coordination/pipeline_lock.rb +263 -53
- data/lib/woods/db/migrations/007_typed_snapshot_units.rb +45 -0
- data/lib/woods/db/migrator.rb +3 -9
- data/lib/woods/db/schema_version.rb +47 -2
- data/lib/woods/dependency_graph.rb +898 -64
- data/lib/woods/embedding/fake.rb +138 -0
- data/lib/woods/embedding/indexer.rb +832 -40
- data/lib/woods/embedding/openai.rb +77 -19
- data/lib/woods/embedding/provider.rb +189 -11
- data/lib/woods/embedding/text_preparer.rb +1 -1
- data/lib/woods/embedding/token_counter.rb +0 -7
- data/lib/woods/evaluation/ablation_agent_payload.rb +38 -0
- data/lib/woods/evaluation/ablation_executor.rb +67 -0
- data/lib/woods/evaluation/ablation_provenance.rb +38 -0
- data/lib/woods/evaluation/ablation_report_writer.rb +43 -0
- data/lib/woods/evaluation/ablation_runner.rb +173 -0
- data/lib/woods/evaluation/ablation_summary.rb +65 -0
- data/lib/woods/evaluation/ablation_task.rb +66 -0
- data/lib/woods/evaluation/ablation_task_set.rb +77 -0
- data/lib/woods/evaluation/ablation_timed_executor.rb +91 -0
- data/lib/woods/evaluation/ablation_worktree.rb +71 -0
- data/lib/woods/evaluation/baseline.rb +60 -0
- data/lib/woods/evaluation/baseline_runner.rb +11 -3
- data/lib/woods/evaluation/evaluator.rb +41 -8
- data/lib/woods/evaluation/query_set.rb +79 -13
- data/lib/woods/evaluation/report_generator.rb +20 -1
- data/lib/woods/export/unit_facts.rb +0 -11
- data/lib/woods/extracted_unit.rb +22 -63
- data/lib/woods/extractor.rb +2783 -238
- data/lib/woods/extractors/action_cable_extractor.rb +9 -4
- data/lib/woods/extractors/ast_source_extraction.rb +20 -2
- data/lib/woods/extractors/caching_extractor.rb +46 -12
- data/lib/woods/extractors/callback_analyzer.rb +39 -9
- data/lib/woods/extractors/component_discovery.rb +123 -0
- data/lib/woods/extractors/concern_extractor.rb +17 -3
- data/lib/woods/extractors/controller_extractor.rb +389 -29
- data/lib/woods/extractors/decorator_extractor.rb +7 -14
- data/lib/woods/extractors/engine_extractor.rb +53 -8
- data/lib/woods/extractors/event_extractor.rb +55 -4
- data/lib/woods/extractors/factory_extractor.rb +49 -11
- data/lib/woods/extractors/graphql_extractor.rb +162 -66
- data/lib/woods/extractors/i18n_extractor.rb +6 -1
- data/lib/woods/extractors/job_extractor.rb +51 -21
- data/lib/woods/extractors/lib_extractor.rb +23 -17
- data/lib/woods/extractors/line_neutralizer.rb +171 -0
- data/lib/woods/extractors/mailer_extractor.rb +9 -1
- data/lib/woods/extractors/manager_extractor.rb +19 -2
- data/lib/woods/extractors/migration_extractor.rb +22 -11
- data/lib/woods/extractors/model_extractor.rb +292 -57
- data/lib/woods/extractors/package_extractor.rb +154 -0
- data/lib/woods/extractors/phlex_extractor.rb +18 -3
- data/lib/woods/extractors/policy_extractor.rb +6 -5
- data/lib/woods/extractors/poro_extractor.rb +13 -14
- data/lib/woods/extractors/pundit_extractor.rb +3 -3
- data/lib/woods/extractors/rails_source_extractor.rb +24 -7
- data/lib/woods/extractors/rake_task_extractor.rb +158 -30
- data/lib/woods/extractors/reference_patterns.rb +38 -0
- data/lib/woods/extractors/route_extractor.rb +58 -2
- data/lib/woods/extractors/scheduled_job_extractor.rb +51 -35
- data/lib/woods/extractors/serializer_extractor.rb +3 -4
- data/lib/woods/extractors/service_extractor.rb +11 -1
- data/lib/woods/extractors/shared_dependency_scanner.rb +24 -34
- data/lib/woods/extractors/shared_utility_methods.rb +36 -6
- data/lib/woods/extractors/source_nesting.rb +560 -0
- data/lib/woods/extractors/state_machine_extractor.rb +30 -18
- data/lib/woods/extractors/test_mapping_extractor.rb +26 -9
- data/lib/woods/extractors/view_component_extractor.rb +28 -3
- data/lib/woods/extractors/view_engines/erb.rb +17 -3
- data/lib/woods/feedback/gap_detector.rb +9 -3
- data/lib/woods/feedback/store.rb +7 -1
- data/lib/woods/filename_utils.rb +29 -1
- data/lib/woods/flow_analysis/operation_extractor.rb +22 -10
- data/lib/woods/flow_assembler.rb +147 -26
- data/lib/woods/flow_document.rb +1 -0
- data/lib/woods/flow_precomputer.rb +175 -22
- data/lib/woods/gem_mapper.rb +285 -0
- data/lib/woods/generation.rb +185 -0
- data/lib/woods/git_command.rb +38 -0
- data/lib/woods/git_provenance.rb +16 -2
- data/lib/woods/graph_analyzer.rb +564 -87
- data/lib/woods/index_artifact.rb +93 -23
- data/lib/woods/mcp/bearer_auth.rb +102 -13
- data/lib/woods/mcp/bootstrap_state.rb +77 -0
- data/lib/woods/mcp/bootstrapper.rb +582 -77
- data/lib/woods/mcp/config_resolver.rb +66 -6
- data/lib/woods/mcp/errors.rb +60 -0
- data/lib/woods/mcp/index_reader.rb +836 -117
- data/lib/woods/mcp/index_reader_pinning.rb +78 -0
- data/lib/woods/mcp/origin_guard.rb +66 -7
- data/lib/woods/mcp/protocol_policy.rb +98 -0
- data/lib/woods/mcp/provider_probe.rb +45 -6
- data/lib/woods/mcp/renderers/markdown_renderer.rb +72 -4
- data/lib/woods/mcp/renderers/plain_renderer.rb +54 -6
- data/lib/woods/mcp/server.rb +898 -152
- data/lib/woods/mcp/tasks/extension.rb +196 -0
- data/lib/woods/mcp/tasks/request_capture.rb +45 -0
- data/lib/woods/mcp/tasks/store.rb +518 -0
- data/lib/woods/mcp/tool_contract.rb +171 -0
- data/lib/woods/mcp/tool_response_renderer.rb +7 -0
- data/lib/woods/model_name_cache.rb +19 -1
- data/lib/woods/notion/client.rb +132 -36
- data/lib/woods/notion/exporter.rb +456 -61
- data/lib/woods/notion/mappers/column_mapper.rb +34 -5
- data/lib/woods/notion/mappers/migration_mapper.rb +32 -8
- data/lib/woods/notion/mappers/model_mapper.rb +21 -6
- data/lib/woods/notion/mappers/shared.rb +45 -3
- data/lib/woods/notion/sync_manifest.rb +258 -0
- data/lib/woods/obsidian/errors.rb +6 -0
- data/lib/woods/obsidian/name_mapper.rb +40 -24
- data/lib/woods/obsidian/vault_exporter.rb +103 -36
- data/lib/woods/operator/pipeline_guard.rb +118 -21
- data/lib/woods/operator/status_reporter.rb +20 -3
- data/lib/woods/path_dispatcher.rb +276 -0
- data/lib/woods/payload_store.rb +236 -0
- data/lib/woods/published_index/edge_shaper.rb +61 -0
- data/lib/woods/published_index/generation_catalog.rb +72 -0
- data/lib/woods/published_index/typed_unit_reader.rb +48 -0
- data/lib/woods/published_index.rb +287 -0
- data/lib/woods/railtie.rb +69 -30
- data/lib/woods/railtie_support.rb +167 -0
- data/lib/woods/release.rb +12 -0
- data/lib/woods/reload_policy.rb +206 -0
- data/lib/woods/resilience/circuit_breaker.rb +47 -8
- data/lib/woods/resilience/index_validator.rb +296 -10
- data/lib/woods/resilience/retryable_provider.rb +71 -6
- data/lib/woods/resolved_config.rb +55 -11
- data/lib/woods/retrieval/context_assembler.rb +132 -40
- data/lib/woods/retrieval/query_classifier.rb +26 -8
- data/lib/woods/retrieval/ranker.rb +193 -28
- data/lib/woods/retrieval/search_executor.rb +206 -39
- data/lib/woods/retriever.rb +317 -71
- data/lib/woods/retry_after.rb +22 -2
- data/lib/woods/ruby_analyzer/class_analyzer.rb +10 -14
- data/lib/woods/ruby_analyzer/fqn_builder.rb +2 -0
- data/lib/woods/ruby_analyzer/mermaid_renderer.rb +14 -4
- data/lib/woods/ruby_analyzer/method_analyzer.rb +1 -1
- data/lib/woods/ruby_analyzer/trace_enricher.rb +3 -0
- data/lib/woods/ruby_analyzer.rb +21 -5
- data/lib/woods/session_tracer/file_store.rb +138 -19
- data/lib/woods/session_tracer/middleware.rb +1 -2
- data/lib/woods/session_tracer/redis_store.rb +122 -12
- data/lib/woods/session_tracer/session_flow_assembler.rb +57 -17
- data/lib/woods/session_tracer/session_flow_document.rb +56 -14
- data/lib/woods/session_tracer/solid_cache_coordination.rb +192 -0
- data/lib/woods/session_tracer/solid_cache_store.rb +560 -91
- data/lib/woods/session_tracer/store.rb +14 -1
- data/lib/woods/storage/metadata_store.rb +230 -26
- data/lib/woods/storage/pgvector.rb +180 -22
- data/lib/woods/storage/qdrant.rb +367 -41
- data/lib/woods/storage/snapshotter/metadata.rb +79 -16
- data/lib/woods/storage/snapshotter/vector.rb +128 -17
- data/lib/woods/storage/snapshotter.rb +23 -5
- data/lib/woods/storage/vector_store.rb +49 -8
- data/lib/woods/storage_identity.rb +28 -0
- data/lib/woods/tasks.rb +53 -2
- data/lib/woods/temporal/json_snapshot_store.rb +112 -42
- data/lib/woods/temporal/snapshot_store.rb +139 -42
- data/lib/woods/unblocked/client.rb +119 -17
- data/lib/woods/unblocked/document_builder.rb +34 -2
- data/lib/woods/unblocked/exporter.rb +63 -27
- data/lib/woods/unblocked/rate_limiter.rb +23 -9
- data/lib/woods/unblocked/sync_manifest.rb +16 -8
- data/lib/woods/update_check.rb +24 -1
- data/lib/woods/util/uuid5.rb +124 -0
- data/lib/woods/version.rb +1 -1
- data/lib/woods/watch/daemon.rb +1345 -0
- data/lib/woods/watch/listen_watcher.rb +81 -0
- data/lib/woods/watch/polling_watcher.rb +137 -0
- data/lib/woods/watch/status.rb +169 -0
- data/lib/woods/watch/tree_scan.rb +163 -0
- data/lib/woods/watch/watcher.rb +100 -0
- data/lib/woods.rb +138 -9
- data/plugin/.claude-plugin/plugin.json +18 -0
- data/plugin/hooks/hooks.json +29 -0
- data/plugin/hooks/woods-post-edit.sh +226 -0
- data/plugin/hooks/woods-session-start.sh +77 -0
- data/plugin/skills/woods-agent-enable/SKILL.md +51 -0
- data/plugin/skills/woods-diagnose/SKILL.md +75 -0
- data/plugin/skills/woods-investigate/SKILL.md +39 -0
- data/plugin/skills/woods-mcp-config/SKILL.md +101 -0
- data/plugin/skills/woods-setup/SKILL.md +99 -0
- metadata +134 -23
- data/lib/woods/console/adapters/cache_adapter.rb +0 -58
- data/lib/woods/console/adapters/good_job_adapter.rb +0 -33
- data/lib/woods/console/adapters/job_adapter.rb +0 -74
- data/lib/woods/console/adapters/sidekiq_adapter.rb +0 -33
- data/lib/woods/console/adapters/solid_queue_adapter.rb +0 -33
- data/lib/woods/console/bridge.rb +0 -210
- data/lib/woods/formatting/claude_adapter.rb +0 -98
- data/lib/woods/formatting/generic_adapter.rb +0 -56
- data/lib/woods/formatting/gpt_adapter.rb +0 -64
- data/lib/woods/notion/mapper.rb +0 -40
- data/lib/woods/observability/health_check.rb +0 -79
- 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 (
|
|
17
|
-
#
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
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
|
-
|
|
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(
|
|
136
|
+
Dir.glob(File.join(units_dir, '**', '*.json')).filter_map do |path|
|
|
76
137
|
next if File.basename(path) == 'checkpoint.json'
|
|
77
138
|
|
|
78
|
-
|
|
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
|
-
|
|
100
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
884
|
+
AtomicFile.write(File.join(@output_dir, 'checkpoint.json'), JSON.generate(checkpoint_payload(checkpoint)))
|
|
267
885
|
end
|
|
268
886
|
|
|
269
|
-
#
|
|
270
|
-
#
|
|
271
|
-
|
|
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
|
-
|
|
275
|
-
|
|
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
|
|
1023
|
+
dump_dir = unique_dump_dir(artifact)
|
|
286
1024
|
|
|
287
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|