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
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require 'net/http'
|
|
4
4
|
require 'json'
|
|
5
|
+
require_relative 'provider'
|
|
5
6
|
|
|
6
7
|
module Woods
|
|
7
8
|
module Embedding
|
|
@@ -17,6 +18,7 @@ module Woods
|
|
|
17
18
|
# vectors = provider.embed_batch(["text1", "text2"])
|
|
18
19
|
class OpenAI
|
|
19
20
|
include Interface
|
|
21
|
+
include DiscardableClient
|
|
20
22
|
|
|
21
23
|
ENDPOINT = URI('https://api.openai.com/v1/embeddings')
|
|
22
24
|
DEFAULT_MODEL = 'text-embedding-3-small'
|
|
@@ -33,9 +35,11 @@ module Woods
|
|
|
33
35
|
|
|
34
36
|
# @param api_key [String] OpenAI API key
|
|
35
37
|
# @param model [String] OpenAI embedding model name (default: text-embedding-3-small)
|
|
36
|
-
|
|
38
|
+
# @param dimensions [Integer, nil] Requested output size for text-embedding-3 models
|
|
39
|
+
def initialize(api_key:, model: DEFAULT_MODEL, dimensions: nil)
|
|
37
40
|
@api_key = api_key
|
|
38
41
|
@model = model
|
|
42
|
+
@dimensions = normalize_dimensions(dimensions)
|
|
39
43
|
end
|
|
40
44
|
|
|
41
45
|
# Embed a single text string.
|
|
@@ -47,8 +51,10 @@ module Woods
|
|
|
47
51
|
def embed(text)
|
|
48
52
|
raise ArgumentError, 'embed(text) requires a non-empty string' if text.nil? || text.to_s.strip.empty?
|
|
49
53
|
|
|
50
|
-
response = post_request(
|
|
51
|
-
response['data'].
|
|
54
|
+
response = post_request(request_body(text))
|
|
55
|
+
vectors = Array(response['data']).map { |item| item['embedding'] }
|
|
56
|
+
VectorValidation.validate!(vectors, expected_count: 1, provider: 'OpenAI')
|
|
57
|
+
vectors.first
|
|
52
58
|
end
|
|
53
59
|
|
|
54
60
|
# Embed multiple texts in a single request.
|
|
@@ -59,16 +65,14 @@ module Woods
|
|
|
59
65
|
# @return [Array<Array<Float>>] array of embedding vectors
|
|
60
66
|
# @raise [Woods::Error] if the API returns an error
|
|
61
67
|
# @raise [ArgumentError] if the array is empty or any element is nil/empty
|
|
62
|
-
def embed_batch(texts)
|
|
68
|
+
def embed_batch(texts)
|
|
63
69
|
raise ArgumentError, 'embed_batch(texts) requires a non-empty array' if texts.nil? || texts.empty?
|
|
64
70
|
if texts.any? { |t| t.nil? || t.to_s.strip.empty? }
|
|
65
71
|
raise ArgumentError, 'embed_batch(texts) rejects nil/empty entries (OpenAI returns 400)'
|
|
66
72
|
end
|
|
67
73
|
|
|
68
|
-
response = post_request(
|
|
69
|
-
response
|
|
70
|
-
.sort_by { |item| item['index'] }
|
|
71
|
-
.map { |item| item['embedding'] }
|
|
74
|
+
response = post_request(request_body(texts))
|
|
75
|
+
extract_validated_batch(response, texts.size)
|
|
72
76
|
end
|
|
73
77
|
|
|
74
78
|
# Return the dimensionality of vectors produced by this model.
|
|
@@ -78,7 +82,7 @@ module Woods
|
|
|
78
82
|
#
|
|
79
83
|
# @return [Integer] number of dimensions
|
|
80
84
|
def dimensions
|
|
81
|
-
DIMENSIONS[@model] || embed('test').length
|
|
85
|
+
@dimensions || DIMENSIONS[@model] || embed('test').length
|
|
82
86
|
end
|
|
83
87
|
|
|
84
88
|
# Return the model name.
|
|
@@ -98,6 +102,38 @@ module Woods
|
|
|
98
102
|
|
|
99
103
|
private
|
|
100
104
|
|
|
105
|
+
# Validate the batch response's shape/cardinality/indexes and
|
|
106
|
+
# return vectors reordered to match the input order.
|
|
107
|
+
#
|
|
108
|
+
# @param response [Hash] parsed JSON response
|
|
109
|
+
# @param expected_count [Integer] number of texts requested
|
|
110
|
+
# @return [Array<Array<Float>>]
|
|
111
|
+
def extract_validated_batch(response, expected_count)
|
|
112
|
+
data = Array(response['data'])
|
|
113
|
+
VectorValidation.validate!(
|
|
114
|
+
data.map { |item| item['embedding'] },
|
|
115
|
+
expected_count: expected_count,
|
|
116
|
+
provider: 'OpenAI',
|
|
117
|
+
indexes: data.map { |item| item['index'] }
|
|
118
|
+
)
|
|
119
|
+
data.sort_by { |item| item['index'] }.map { |item| item['embedding'] }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def request_body(input)
|
|
123
|
+
{ model: @model, input: input }.tap do |body|
|
|
124
|
+
body[:dimensions] = @dimensions if @dimensions
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def normalize_dimensions(value)
|
|
129
|
+
return if value.nil?
|
|
130
|
+
|
|
131
|
+
dimensions = Integer(value)
|
|
132
|
+
raise ArgumentError, "dimensions must be positive, got #{value.inspect}" unless dimensions.positive?
|
|
133
|
+
|
|
134
|
+
dimensions
|
|
135
|
+
end
|
|
136
|
+
|
|
101
137
|
# Cap interpolated response bodies so misconfigured API errors
|
|
102
138
|
# (which occasionally echo request metadata, including headers) don't
|
|
103
139
|
# unbounded-leak into logs or re-raised messages.
|
|
@@ -116,7 +152,7 @@ module Woods
|
|
|
116
152
|
# @param body [Hash] request body
|
|
117
153
|
# @return [Hash] parsed JSON response
|
|
118
154
|
# @raise [Woods::Error] if the API returns a non-success status
|
|
119
|
-
def post_request(body)
|
|
155
|
+
def post_request(body)
|
|
120
156
|
request = Net::HTTP::Post.new(ENDPOINT.path)
|
|
121
157
|
request['Content-Type'] = 'application/json'
|
|
122
158
|
request['Authorization'] = "Bearer #{@api_key}"
|
|
@@ -124,22 +160,44 @@ module Woods
|
|
|
124
160
|
|
|
125
161
|
response = http_client.request(request)
|
|
126
162
|
|
|
127
|
-
unless response.is_a?(Net::HTTPSuccess)
|
|
128
|
-
raise Woods::Error, "OpenAI API error: #{response.code} #{truncate_response_body(response.body)}"
|
|
129
|
-
end
|
|
163
|
+
raise request_error(response) unless response.is_a?(Net::HTTPSuccess)
|
|
130
164
|
|
|
131
165
|
JSON.parse(response.body)
|
|
132
|
-
rescue Errno::ECONNRESET, Net::OpenTimeout, IOError
|
|
133
|
-
# Connection dropped — reset and retry once
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
166
|
+
rescue Errno::ECONNRESET, Net::OpenTimeout, Net::ReadTimeout, IOError
|
|
167
|
+
# Connection dropped — reset and retry once. A second transport
|
|
168
|
+
# failure is wrapped (mirroring Ollama) rather than left to escape
|
|
169
|
+
# as a raw Errno, so callers rescuing Woods::Error see a typed,
|
|
170
|
+
# context-bearing failure.
|
|
171
|
+
#
|
|
172
|
+
# Net::ReadTimeout descends from Timeout::Error, not IOError, so it
|
|
173
|
+
# needs naming explicitly or a stalled response escapes both the
|
|
174
|
+
# retry and the typing (STO-15).
|
|
175
|
+
discard_http_client
|
|
176
|
+
begin
|
|
177
|
+
response = http_client.request(request)
|
|
178
|
+
rescue StandardError => retry_error
|
|
179
|
+
raise RequestError, "OpenAI API error (retry failed): #{retry_error.message}"
|
|
138
180
|
end
|
|
181
|
+
raise request_error(response) unless response.is_a?(Net::HTTPSuccess)
|
|
139
182
|
|
|
140
183
|
JSON.parse(response.body)
|
|
141
184
|
end
|
|
142
185
|
|
|
186
|
+
# Build a {RequestError} from a non-success OpenAI response,
|
|
187
|
+
# attaching the HTTP status and any +Retry-After+ header so the
|
|
188
|
+
# resilience layer can classify the failure (429/5xx retryable,
|
|
189
|
+
# 400/401 not) and honor the server-requested back-off.
|
|
190
|
+
#
|
|
191
|
+
# @param response [Net::HTTPResponse]
|
|
192
|
+
# @return [RequestError]
|
|
193
|
+
def request_error(response)
|
|
194
|
+
RequestError.new(
|
|
195
|
+
"OpenAI API error: #{response.code} #{truncate_response_body(response.body)}",
|
|
196
|
+
http_status: response.code.to_i,
|
|
197
|
+
retry_after: response['Retry-After']
|
|
198
|
+
)
|
|
199
|
+
end
|
|
200
|
+
|
|
143
201
|
# Return a reusable, started HTTP client for the OpenAI API.
|
|
144
202
|
# Calling http.start opens a persistent TCP connection so
|
|
145
203
|
# keep_alive_timeout actually takes effect across requests.
|
|
@@ -4,6 +4,11 @@ require 'net/http'
|
|
|
4
4
|
require 'json'
|
|
5
5
|
|
|
6
6
|
module Woods
|
|
7
|
+
# Standalone-load guard — keeps `require 'woods/embedding/provider'`
|
|
8
|
+
# working without the top-level woods.rb (same pattern as storage/
|
|
9
|
+
# and console/). {Provider::RequestError} subclasses this at load time.
|
|
10
|
+
class Error < StandardError; end unless defined?(Woods::Error)
|
|
11
|
+
|
|
7
12
|
module Embedding
|
|
8
13
|
# Interface and adapters for embedding providers.
|
|
9
14
|
#
|
|
@@ -61,6 +66,151 @@ module Woods
|
|
|
61
66
|
end
|
|
62
67
|
end
|
|
63
68
|
|
|
69
|
+
# Shared connection hygiene for adapters that hold one persistent
|
|
70
|
+
# Net::HTTP client in +@http_client+. Closing a connection before
|
|
71
|
+
# dropping the reference keeps the descriptor from lingering until GC.
|
|
72
|
+
module DiscardableClient
|
|
73
|
+
# Close the persistent connection before dropping the reference.
|
|
74
|
+
# Nil-ing +@http_client+ alone abandons the open socket — the
|
|
75
|
+
# descriptor stays allocated until GC finalizes the object — so
|
|
76
|
+
# finish it first. Finish raises IOError on a session that was never
|
|
77
|
+
# started, and a connection that died mid-request can refuse to close
|
|
78
|
+
# cleanly; the rescue keeps the discard best-effort and only
|
|
79
|
+
# guarantees the reference is dropped.
|
|
80
|
+
#
|
|
81
|
+
# @return [void]
|
|
82
|
+
def discard_http_client
|
|
83
|
+
@http_client&.finish
|
|
84
|
+
rescue StandardError
|
|
85
|
+
nil
|
|
86
|
+
ensure
|
|
87
|
+
@http_client = nil
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Raised when an embedding API answers with a non-success HTTP
|
|
92
|
+
# response. Carries the HTTP status and the raw +Retry-After+ header
|
|
93
|
+
# (when the server sent one) so {Woods::Resilience::RetryableProvider}
|
|
94
|
+
# can distinguish transient failures (429 rate limits, 5xx) from
|
|
95
|
+
# hopeless ones (400 bad request, 401 bad credentials) and honor the
|
|
96
|
+
# server's requested back-off instead of its own schedule.
|
|
97
|
+
#
|
|
98
|
+
# Subclasses {Woods::Error}, so existing `rescue Woods::Error` call
|
|
99
|
+
# sites keep working unchanged.
|
|
100
|
+
class RequestError < Woods::Error
|
|
101
|
+
# @return [Integer, nil] HTTP status code of the failed response
|
|
102
|
+
attr_reader :http_status
|
|
103
|
+
|
|
104
|
+
# @return [String, nil] raw +Retry-After+ header value (delta-seconds
|
|
105
|
+
# or HTTP-date form), or nil when the server sent none
|
|
106
|
+
attr_reader :retry_after
|
|
107
|
+
|
|
108
|
+
# @param message [String] human-readable error message
|
|
109
|
+
# @param http_status [Integer, nil] HTTP status code
|
|
110
|
+
# @param retry_after [String, nil] raw +Retry-After+ header value
|
|
111
|
+
def initialize(message, http_status: nil, retry_after: nil)
|
|
112
|
+
super(message)
|
|
113
|
+
@http_status = http_status
|
|
114
|
+
@retry_after = retry_after
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# Raised when a provider's embedding response is malformed: wrong
|
|
119
|
+
# cardinality, missing/duplicate OpenAI response indexes, a
|
|
120
|
+
# non-finite/non-numeric or empty vector, or a vector whose dimension
|
|
121
|
+
# disagrees with the rest of the batch. Left unvalidated, any of
|
|
122
|
+
# these stores nil or mis-paired vectors and silently corrupts the
|
|
123
|
+
# vector store.
|
|
124
|
+
class InvalidEmbeddingResponse < Woods::Error
|
|
125
|
+
# @return [String] the provider label ("OpenAI", "Ollama")
|
|
126
|
+
attr_reader :provider
|
|
127
|
+
|
|
128
|
+
# @return [Integer] the number of texts in the request this response answers
|
|
129
|
+
attr_reader :batch_size
|
|
130
|
+
|
|
131
|
+
# @param message [String] what specifically was wrong with the response
|
|
132
|
+
# @param provider [String] provider label
|
|
133
|
+
# @param batch_size [Integer] number of texts requested
|
|
134
|
+
def initialize(message, provider:, batch_size:)
|
|
135
|
+
@provider = provider
|
|
136
|
+
@batch_size = batch_size
|
|
137
|
+
super("#{provider} embedding response invalid for batch of #{batch_size}: #{message}")
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Shared response-shape validation for embedding providers. A short
|
|
142
|
+
# or malformed provider response — fewer vectors than requested,
|
|
143
|
+
# duplicate/missing OpenAI response indexes, a NaN/Infinity/nil
|
|
144
|
+
# entry, or a dimension that drifts partway through a batch — would
|
|
145
|
+
# otherwise store nil or mis-paired vectors with no error at all,
|
|
146
|
+
# corrupting the index silently. Every provider's `embed`/
|
|
147
|
+
# `embed_batch` must call {.validate!} before returning.
|
|
148
|
+
module VectorValidation
|
|
149
|
+
module_function
|
|
150
|
+
|
|
151
|
+
# @param vectors [Array<Array<Numeric>>] vectors about to be returned/stored,
|
|
152
|
+
# in whatever order the caller has them (order doesn't matter for these checks)
|
|
153
|
+
# @param expected_count [Integer] number of texts in the request
|
|
154
|
+
# @param provider [String] provider label used in the raised error's message
|
|
155
|
+
# @param indexes [Array<Integer>, nil] raw response `index` values, when the
|
|
156
|
+
# provider's wire format carries them (OpenAI). Ollama has no index field —
|
|
157
|
+
# its response order is positional — so callers pass nil there and this
|
|
158
|
+
# check is skipped.
|
|
159
|
+
# @raise [InvalidEmbeddingResponse] on any violation
|
|
160
|
+
# @return [void]
|
|
161
|
+
def validate!(vectors, expected_count:, provider:, indexes: nil)
|
|
162
|
+
fail_with = lambda do |msg|
|
|
163
|
+
raise InvalidEmbeddingResponse.new(msg, provider: provider, batch_size: expected_count)
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
unless vectors.size == expected_count
|
|
167
|
+
fail_with.call("expected #{expected_count} vector(s), got #{vectors.size}")
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
validate_indexes!(indexes, expected_count, fail_with) if indexes
|
|
171
|
+
|
|
172
|
+
validate_vector_shapes!(vectors, fail_with)
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# @api private
|
|
176
|
+
def validate_indexes!(indexes, expected_count, fail_with)
|
|
177
|
+
if indexes.any? { |i| !i.is_a?(Integer) }
|
|
178
|
+
fail_with.call("response indexes must all be integers, got #{indexes.inspect}")
|
|
179
|
+
end
|
|
180
|
+
fail_with.call("response indexes are not unique: #{indexes.sort}") if indexes.uniq.size != indexes.size
|
|
181
|
+
|
|
182
|
+
expected_indexes = (0...expected_count).to_a
|
|
183
|
+
return if indexes.sort == expected_indexes
|
|
184
|
+
|
|
185
|
+
fail_with.call("response indexes #{indexes.sort} do not cover 0..#{expected_count - 1}")
|
|
186
|
+
end
|
|
187
|
+
private_class_method :validate_indexes!
|
|
188
|
+
|
|
189
|
+
# @api private
|
|
190
|
+
def validate_vector_shapes!(vectors, fail_with)
|
|
191
|
+
dimension = nil
|
|
192
|
+
vectors.each_with_index do |vector, i|
|
|
193
|
+
check_vector_shape!(vector, i, fail_with)
|
|
194
|
+
dimension ||= vector.size
|
|
195
|
+
next if vector.size == dimension
|
|
196
|
+
|
|
197
|
+
fail_with.call("vector at position #{i} has dimension #{vector.size}, expected #{dimension}")
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
private_class_method :validate_vector_shapes!
|
|
201
|
+
|
|
202
|
+
# @api private
|
|
203
|
+
def check_vector_shape!(vector, index, fail_with)
|
|
204
|
+
unless vector.is_a?(Array) && !vector.empty?
|
|
205
|
+
fail_with.call("vector at position #{index} is not a non-empty array (got #{vector.class})")
|
|
206
|
+
end
|
|
207
|
+
return if vector.all? { |n| n.is_a?(Numeric) && n.finite? }
|
|
208
|
+
|
|
209
|
+
fail_with.call("vector at position #{index} contains a non-finite or non-numeric value")
|
|
210
|
+
end
|
|
211
|
+
private_class_method :check_vector_shape!
|
|
212
|
+
end
|
|
213
|
+
|
|
64
214
|
# Ollama adapter for local embeddings via the Ollama HTTP API.
|
|
65
215
|
#
|
|
66
216
|
# Uses the `/api/embed` endpoint to generate embeddings. Requires a running
|
|
@@ -72,6 +222,7 @@ module Woods
|
|
|
72
222
|
# vectors = provider.embed_batch(["text1", "text2"])
|
|
73
223
|
class Ollama
|
|
74
224
|
include Interface
|
|
225
|
+
include DiscardableClient
|
|
75
226
|
|
|
76
227
|
DEFAULT_MODEL = 'nomic-embed-text'
|
|
77
228
|
DEFAULT_HOST = 'http://localhost:11434'
|
|
@@ -118,13 +269,15 @@ module Woods
|
|
|
118
269
|
# context from `MODEL_CONTEXT_LENGTHS`, falling back to 2048 for
|
|
119
270
|
# unknown models. Set explicitly only if running a model with a
|
|
120
271
|
# known-larger native context that isn't in the registry yet.
|
|
272
|
+
# @param dimensions [Integer, nil] Requested output vector size.
|
|
121
273
|
# @param read_timeout [Integer] HTTP read timeout in seconds.
|
|
122
274
|
# Bump this for slow / cold-start hosts or very large batches.
|
|
123
275
|
def initialize(model: DEFAULT_MODEL, host: DEFAULT_HOST, num_ctx: nil,
|
|
124
|
-
read_timeout: DEFAULT_READ_TIMEOUT)
|
|
276
|
+
dimensions: nil, read_timeout: DEFAULT_READ_TIMEOUT)
|
|
125
277
|
@model = model
|
|
126
278
|
@host = host
|
|
127
279
|
@num_ctx = num_ctx || MODEL_CONTEXT_LENGTHS.fetch(model, FALLBACK_NUM_CTX)
|
|
280
|
+
@dimensions = normalize_dimensions(dimensions)
|
|
128
281
|
@read_timeout = read_timeout
|
|
129
282
|
@uri = URI("#{host}/api/embed")
|
|
130
283
|
end
|
|
@@ -139,7 +292,9 @@ module Woods
|
|
|
139
292
|
raise ArgumentError, 'embed(text) requires a non-empty string' if text.nil? || text.to_s.strip.empty?
|
|
140
293
|
|
|
141
294
|
response = post_request(build_body(text))
|
|
142
|
-
response['embeddings']
|
|
295
|
+
vectors = Array(response['embeddings'])
|
|
296
|
+
VectorValidation.validate!(vectors, expected_count: 1, provider: 'Ollama')
|
|
297
|
+
vectors.first
|
|
143
298
|
end
|
|
144
299
|
|
|
145
300
|
# Embed multiple texts in a single request.
|
|
@@ -155,7 +310,9 @@ module Woods
|
|
|
155
310
|
end
|
|
156
311
|
|
|
157
312
|
response = post_request(build_body(texts))
|
|
158
|
-
response['embeddings']
|
|
313
|
+
vectors = Array(response['embeddings'])
|
|
314
|
+
VectorValidation.validate!(vectors, expected_count: texts.size, provider: 'Ollama')
|
|
315
|
+
vectors
|
|
159
316
|
end
|
|
160
317
|
|
|
161
318
|
# Return the dimensionality of vectors produced by this model.
|
|
@@ -186,6 +343,15 @@ module Woods
|
|
|
186
343
|
|
|
187
344
|
private
|
|
188
345
|
|
|
346
|
+
def normalize_dimensions(value)
|
|
347
|
+
return if value.nil?
|
|
348
|
+
|
|
349
|
+
dimensions = Integer(value)
|
|
350
|
+
raise ArgumentError, "dimensions must be positive, got #{value.inspect}" unless dimensions.positive?
|
|
351
|
+
|
|
352
|
+
dimensions
|
|
353
|
+
end
|
|
354
|
+
|
|
189
355
|
# Cap interpolated response bodies so misconfigured Ollama responses
|
|
190
356
|
# (e.g. proxied HTML error pages) don't unbounded-leak into logs or
|
|
191
357
|
# re-raised error messages.
|
|
@@ -204,6 +370,7 @@ module Woods
|
|
|
204
370
|
# tokens and returns 400 when the input exceeds that default.
|
|
205
371
|
def build_body(input)
|
|
206
372
|
body = { model: @model, input: input }
|
|
373
|
+
body[:dimensions] = @dimensions if @dimensions
|
|
207
374
|
body[:options] = { num_ctx: @num_ctx } if @num_ctx
|
|
208
375
|
body
|
|
209
376
|
end
|
|
@@ -213,31 +380,42 @@ module Woods
|
|
|
213
380
|
# @param body [Hash] request body
|
|
214
381
|
# @return [Hash] parsed JSON response
|
|
215
382
|
# @raise [Woods::Error] if the API returns a non-success status
|
|
216
|
-
def post_request(body)
|
|
383
|
+
def post_request(body)
|
|
217
384
|
request = Net::HTTP::Post.new(@uri.path, 'Content-Type' => 'application/json')
|
|
218
385
|
request.body = body.to_json
|
|
219
386
|
response = http_client.request(request)
|
|
220
387
|
|
|
221
|
-
unless response.is_a?(Net::HTTPSuccess)
|
|
222
|
-
raise Woods::Error, "Ollama API error: #{response.code} #{truncate_response_body(response.body)}"
|
|
223
|
-
end
|
|
388
|
+
raise request_error(response) unless response.is_a?(Net::HTTPSuccess)
|
|
224
389
|
|
|
225
390
|
JSON.parse(response.body)
|
|
226
391
|
rescue Errno::ECONNRESET, Net::OpenTimeout, Net::ReadTimeout, IOError
|
|
227
392
|
# Connection dropped — reset and retry once
|
|
228
|
-
|
|
393
|
+
discard_http_client
|
|
229
394
|
begin
|
|
230
395
|
response = http_client.request(request)
|
|
231
396
|
rescue StandardError => retry_error
|
|
232
397
|
raise Woods::Error, "Ollama API error (retry failed): #{retry_error.message}"
|
|
233
398
|
end
|
|
234
|
-
unless response.is_a?(Net::HTTPSuccess)
|
|
235
|
-
raise Woods::Error, "Ollama API error: #{response.code} #{truncate_response_body(response.body)}"
|
|
236
|
-
end
|
|
399
|
+
raise request_error(response) unless response.is_a?(Net::HTTPSuccess)
|
|
237
400
|
|
|
238
401
|
JSON.parse(response.body)
|
|
239
402
|
end
|
|
240
403
|
|
|
404
|
+
# Build a {RequestError} from a non-success Ollama response,
|
|
405
|
+
# attaching the HTTP status and any +Retry-After+ header so the
|
|
406
|
+
# resilience layer can classify the failure and honor the
|
|
407
|
+
# server-requested back-off.
|
|
408
|
+
#
|
|
409
|
+
# @param response [Net::HTTPResponse]
|
|
410
|
+
# @return [RequestError]
|
|
411
|
+
def request_error(response)
|
|
412
|
+
RequestError.new(
|
|
413
|
+
"Ollama API error: #{response.code} #{truncate_response_body(response.body)}",
|
|
414
|
+
http_status: response.code.to_i,
|
|
415
|
+
retry_after: response['Retry-After']
|
|
416
|
+
)
|
|
417
|
+
end
|
|
418
|
+
|
|
241
419
|
# Return a reusable, started HTTP client for the Ollama API.
|
|
242
420
|
#
|
|
243
421
|
# @return [Net::HTTP]
|
|
@@ -6,7 +6,7 @@ module Woods
|
|
|
6
6
|
module Embedding
|
|
7
7
|
# Prepares ExtractedUnit data for embedding by building context-prefixed text.
|
|
8
8
|
#
|
|
9
|
-
# Follows the context prefix format
|
|
9
|
+
# Follows the context prefix format (see `git log --follow -- docs/design/CONTEXT_AND_CHUNKING.md`):
|
|
10
10
|
# [type] identifier
|
|
11
11
|
# namespace: ...
|
|
12
12
|
# file: ...
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Woods
|
|
6
|
+
module Evaluation
|
|
7
|
+
# Parses the `claude -p --output-format json` shaped payload an ablation
|
|
8
|
+
# agent prints on stdout (#280).
|
|
9
|
+
module AblationAgentPayload
|
|
10
|
+
TOKEN_KEYS = %w[input_tokens output_tokens cache_creation_input_tokens cache_read_input_tokens].freeze
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# The last line that parses as a JSON object wins, so a preamble on
|
|
15
|
+
# stdout does not hide the result.
|
|
16
|
+
#
|
|
17
|
+
# @param stdout [String]
|
|
18
|
+
# @return [Hash, nil]
|
|
19
|
+
def parse(stdout)
|
|
20
|
+
stdout.to_s.lines.reverse_each do |line|
|
|
21
|
+
parsed = JSON.parse(line)
|
|
22
|
+
return parsed if parsed.is_a?(Hash)
|
|
23
|
+
rescue JSON::ParserError
|
|
24
|
+
next
|
|
25
|
+
end
|
|
26
|
+
nil
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @param usage [Hash, nil]
|
|
30
|
+
# @return [Integer, nil]
|
|
31
|
+
def token_total(usage)
|
|
32
|
+
return nil unless usage.is_a?(Hash)
|
|
33
|
+
|
|
34
|
+
TOKEN_KEYS.sum { |key| usage[key].to_i }
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'timeout'
|
|
4
|
+
|
|
5
|
+
module Woods
|
|
6
|
+
module Evaluation
|
|
7
|
+
# Default executor for {AblationRunner}: spawns a real subprocess and
|
|
8
|
+
# exposes the pid of the most recent spawn via {#pid} (#280 review), so
|
|
9
|
+
# {AblationTimedExecutor} can terminate it on timeout. `Open3.capture3`
|
|
10
|
+
# blocks synchronously and never surfaces a pid to the caller, which is
|
|
11
|
+
# exactly the gap that let a timed-out agent keep running in the
|
|
12
|
+
# background.
|
|
13
|
+
class AblationExecutor
|
|
14
|
+
# @return [Integer, nil] the pid of the most recently spawned process
|
|
15
|
+
attr_reader :pid
|
|
16
|
+
|
|
17
|
+
# @param command [String] a full shell command line
|
|
18
|
+
# @param chdir [String]
|
|
19
|
+
# @return [Array(String, String, Boolean)] stdout, stderr, success
|
|
20
|
+
def call(command, chdir:)
|
|
21
|
+
stdout_r, stdout_w = IO.pipe
|
|
22
|
+
stderr_r, stderr_w = IO.pipe
|
|
23
|
+
spawn_child(command, chdir, stdout_w, stderr_w)
|
|
24
|
+
stdout_w.close
|
|
25
|
+
stderr_w.close
|
|
26
|
+
|
|
27
|
+
# Read concurrently with the wait, not after it: a pipe holds only
|
|
28
|
+
# ~64KB on Linux before a write blocks, so a child that writes more
|
|
29
|
+
# than that to either stream, with nothing draining it, blocks
|
|
30
|
+
# forever, and Process.wait2 alone never touches either pipe. That
|
|
31
|
+
# made AblationTimedExecutor report a false timeout on a chatty
|
|
32
|
+
# agent instead of ever reaching a real result.
|
|
33
|
+
stdout_thread = reader_thread(stdout_r)
|
|
34
|
+
stderr_thread = reader_thread(stderr_r)
|
|
35
|
+
_reaped_pid, status = Process.wait2(@pid)
|
|
36
|
+
[stdout_thread.value, stderr_thread.value, status.success?]
|
|
37
|
+
ensure
|
|
38
|
+
stdout_r&.close
|
|
39
|
+
stderr_r&.close
|
|
40
|
+
stdout_w&.close unless stdout_w&.closed?
|
|
41
|
+
stderr_w&.close unless stderr_w&.closed?
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
# A timeout wrapping #call can interrupt it mid-read, closing the pipe
|
|
47
|
+
# out from under whichever reader thread is still blocked on it. That
|
|
48
|
+
# IOError is expected and the caller already discards this call's
|
|
49
|
+
# result, so it should not be reported as an unhandled thread
|
|
50
|
+
# exception.
|
|
51
|
+
def reader_thread(io)
|
|
52
|
+
Thread.new { io.read }.tap { |thread| thread.report_on_exception = false }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# `Thread.handle_interrupt` defers the async Timeout::Error
|
|
56
|
+
# AblationTimedExecutor's Timeout.timeout can raise into this thread,
|
|
57
|
+
# so it cannot land between Process.spawn returning and `@pid` being
|
|
58
|
+
# set (#280 review, minor): a pid lost that way could never be
|
|
59
|
+
# terminated on timeout.
|
|
60
|
+
def spawn_child(command, chdir, stdout_w, stderr_w)
|
|
61
|
+
Thread.handle_interrupt(Timeout::Error => :never) do
|
|
62
|
+
@pid = Process.spawn(command, chdir: chdir, in: File::NULL, out: stdout_w, err: stderr_w)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Woods
|
|
6
|
+
module Evaluation
|
|
7
|
+
# Per-trial provenance (a binding ruling): the exact agent command run,
|
|
8
|
+
# the model the agent reported (when the JSON payload names one), the
|
|
9
|
+
# MCP wiring in effect, the Woods generation on disk in the checkout,
|
|
10
|
+
# and the baseline SHA the checkout came from (#280).
|
|
11
|
+
AblationProvenance = Struct.new(:agent_command, :model, :config, :woods_generation, :baseline_sha,
|
|
12
|
+
keyword_init: true) do
|
|
13
|
+
# @return [AblationProvenance]
|
|
14
|
+
def self.build(agent_command:, chdir:, baseline_sha:)
|
|
15
|
+
new(agent_command: agent_command, model: nil, config: mcp_config_of(agent_command),
|
|
16
|
+
woods_generation: woods_generation_at(chdir), baseline_sha: baseline_sha)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.mcp_config_of(command)
|
|
20
|
+
return 'strict' if command.include?('--strict-mcp-config')
|
|
21
|
+
|
|
22
|
+
match = command.match(/--mcp-config[= ](\S+)/)
|
|
23
|
+
match && match[1]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.woods_generation_at(chdir)
|
|
27
|
+
generation_file = File.join(chdir, 'tmp', 'woods', 'generation.json')
|
|
28
|
+
return nil unless File.exist?(generation_file)
|
|
29
|
+
|
|
30
|
+
JSON.parse(File.read(generation_file))['number']
|
|
31
|
+
rescue StandardError
|
|
32
|
+
nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private_class_method :mcp_config_of, :woods_generation_at
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
|
|
6
|
+
module Woods
|
|
7
|
+
module Evaluation
|
|
8
|
+
# Serializes an {AblationRunner::Report} to JSON and renders the terminal
|
|
9
|
+
# summary for `woods:evaluate:ablation` (#280).
|
|
10
|
+
class AblationReportWriter
|
|
11
|
+
# @param report [AblationRunner::Report]
|
|
12
|
+
def initialize(report)
|
|
13
|
+
@report = report
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# @param path [String]
|
|
17
|
+
# @return [void]
|
|
18
|
+
def write(path)
|
|
19
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
20
|
+
results = @report.results.map { |result| result.to_h.merge(provenance: result.provenance.to_h) }
|
|
21
|
+
File.write(path, JSON.pretty_generate('summary' => @report.summary, 'results' => results))
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# @return [String] a human-readable summary, explicit that this is a
|
|
25
|
+
# paired-run harness rather than a causal-evidence report
|
|
26
|
+
def summary_text
|
|
27
|
+
lines = ['', 'Ablation complete! This is a harness for paired runs, not causal evidence.', '=' * 50]
|
|
28
|
+
%i[on off delta].each { |condition| lines.concat(condition_lines(condition)) }
|
|
29
|
+
lines << ('=' * 50)
|
|
30
|
+
lines.join("\n")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def condition_lines(condition)
|
|
36
|
+
section = @report.summary[condition]
|
|
37
|
+
return [] unless section
|
|
38
|
+
|
|
39
|
+
[" #{condition}:"] + section.map { |key, value| " #{key.to_s.ljust(18)}: #{value.nil? ? 'n/a' : value}" }
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|