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