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
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'shellwords'
|
|
4
|
+
|
|
5
|
+
require_relative 'ablation_worktree'
|
|
6
|
+
require_relative 'ablation_summary'
|
|
7
|
+
require_relative 'ablation_provenance'
|
|
8
|
+
require_relative 'ablation_agent_payload'
|
|
9
|
+
require_relative 'ablation_executor'
|
|
10
|
+
require_relative 'ablation_timed_executor'
|
|
11
|
+
|
|
12
|
+
module Woods
|
|
13
|
+
module Evaluation
|
|
14
|
+
# Runs every task in an {AblationTaskSet} twice, index on and index off,
|
|
15
|
+
# each trial in its own disposable {AblationWorktree} checked out from a
|
|
16
|
+
# fixed baseline commit, and reports resolution rate, tokens, cost,
|
|
17
|
+
# turns, and errors per condition (#280).
|
|
18
|
+
#
|
|
19
|
+
# This is a harness for collecting paired on/off runs, not a source of
|
|
20
|
+
# causal evidence: sample size, task selection, and agent nondeterminism
|
|
21
|
+
# all bear on what a result means. See docs/EVALUATION.md.
|
|
22
|
+
#
|
|
23
|
+
# The agent is any command that prints one JSON object on stdout in the
|
|
24
|
+
# `claude -p --output-format json` shape. The executor is injectable so
|
|
25
|
+
# the runner never invokes a real agent in specs.
|
|
26
|
+
#
|
|
27
|
+
# Every command a trial runs (worktree add/remove, the optional reset,
|
|
28
|
+
# the agent invocation, and the check) shares one {AblationTimedExecutor}
|
|
29
|
+
# and is therefore bound by the same `timeout`, applied per call rather
|
|
30
|
+
# than once for the whole trial: a slow reset does not eat into the
|
|
31
|
+
# agent's budget, and vice versa. A timed-out call is terminated (TERM,
|
|
32
|
+
# then KILL if still alive after a short grace period) when the
|
|
33
|
+
# underlying executor exposes a pid.
|
|
34
|
+
#
|
|
35
|
+
# @example
|
|
36
|
+
# set = AblationTaskSet.load('config/eval_ablation.json')
|
|
37
|
+
# report = AblationRunner.new(task_set: set, workdir: Rails.root.to_s).run
|
|
38
|
+
# report.summary[:delta][:mean_tokens] # => negative when the index saves tokens
|
|
39
|
+
#
|
|
40
|
+
class AblationRunner
|
|
41
|
+
Result = Struct.new(:task_id, :condition, :resolved, :total_tokens, :cost_usd, :turns, :duration_ms,
|
|
42
|
+
:error, :provenance, keyword_init: true)
|
|
43
|
+
Report = Struct.new(:results, :summary, keyword_init: true)
|
|
44
|
+
|
|
45
|
+
DEFAULT_TIMEOUT = 600
|
|
46
|
+
|
|
47
|
+
# Verifies Woods availability before a trial runs, distinguishing "MCP
|
|
48
|
+
# enabled" (the agent command is wired to reach the Woods MCP server)
|
|
49
|
+
# from "index present" (an index actually exists in the checkout): the
|
|
50
|
+
# `:on` condition needs both. The `:off` condition needs both signals
|
|
51
|
+
# too, in the negative: `--strict-mcp-config` declared, and no
|
|
52
|
+
# `--mcp-config` reference at all. A command carrying both flags is
|
|
53
|
+
# still wired to Woods and fails preflight.
|
|
54
|
+
DEFAULT_WOODS_PROBE = lambda do |condition, chdir, command|
|
|
55
|
+
mcp_wired = command.include?('--mcp-config')
|
|
56
|
+
strict = command.include?('--strict-mcp-config')
|
|
57
|
+
index_present = File.exist?(File.join(chdir, 'tmp', 'woods', 'generation.json'))
|
|
58
|
+
if condition == :on
|
|
59
|
+
mcp_wired && index_present
|
|
60
|
+
else
|
|
61
|
+
strict && !mcp_wired
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# @param task_set [AblationTaskSet::Definition]
|
|
66
|
+
# @param workdir [String] the host application root
|
|
67
|
+
# @param executor [#call] `call(command, chdir:)` returning `[stdout, stderr, success]`.
|
|
68
|
+
# Wrapped in an {AblationTimedExecutor} exactly once, so every command a trial runs
|
|
69
|
+
# through it (worktree add/remove, reset, agent, check) shares the same per-call
|
|
70
|
+
# timeout; see `timeout` below.
|
|
71
|
+
# @param conditions [Array<Symbol>] subset of `%i[on off]`
|
|
72
|
+
# @param trial_options [Hash]
|
|
73
|
+
# @option trial_options [Numeric] :timeout seconds allowed per call the trial makes
|
|
74
|
+
# (worktree add/remove, the optional reset, the agent invocation, and the check),
|
|
75
|
+
# applied independently to each one rather than shared across the whole trial
|
|
76
|
+
# @option trial_options [#call] :woods_probe `call(condition, chdir, command)` returning a boolean
|
|
77
|
+
def initialize(task_set:, workdir:, executor: nil, conditions: %i[on off], **trial_options)
|
|
78
|
+
@task_set = task_set
|
|
79
|
+
@workdir = workdir
|
|
80
|
+
@conditions = conditions
|
|
81
|
+
@timeout = trial_options.fetch(:timeout, DEFAULT_TIMEOUT)
|
|
82
|
+
@woods_probe = trial_options.fetch(:woods_probe, DEFAULT_WOODS_PROBE)
|
|
83
|
+
@executor = AblationTimedExecutor.new(executor || AblationExecutor.new, timeout: @timeout)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# @return [Report]
|
|
87
|
+
def run
|
|
88
|
+
baseline_sha = resolve_baseline_sha
|
|
89
|
+
results = @task_set.tasks.flat_map do |task|
|
|
90
|
+
@conditions.map { |condition| run_trial(task, condition, baseline_sha) }
|
|
91
|
+
end
|
|
92
|
+
Report.new(results: results, summary: AblationSummary.build(results, @conditions))
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private
|
|
96
|
+
|
|
97
|
+
def resolve_baseline_sha
|
|
98
|
+
out, _err, ok = @executor.call('git rev-parse HEAD', chdir: @workdir)
|
|
99
|
+
ok ? out.strip : nil
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def run_trial(task, condition, baseline_sha)
|
|
103
|
+
worktree = AblationWorktree.new(repo_root: @workdir, baseline_sha: baseline_sha, executor: @executor)
|
|
104
|
+
result = nil
|
|
105
|
+
setup_error = worktree.trial(@task_set.reset) { |path| result = execute(task, condition, path, baseline_sha) }
|
|
106
|
+
result || failed_result(task, condition, blank_provenance(condition, baseline_sha), setup_error)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def execute(task, condition, path, baseline_sha)
|
|
110
|
+
chdir = File.expand_path(task.workdir, path)
|
|
111
|
+
command = agent_command(condition).sub('{prompt}', Shellwords.escape(task.prompt))
|
|
112
|
+
provenance = AblationProvenance.build(agent_command: command, chdir: chdir, baseline_sha: baseline_sha)
|
|
113
|
+
|
|
114
|
+
probe_error = preflight_error(condition, chdir, command)
|
|
115
|
+
return failed_result(task, condition, provenance, probe_error) if probe_error
|
|
116
|
+
|
|
117
|
+
run_agent_and_check(task, condition, chdir, command, provenance)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def run_agent_and_check(task, condition, chdir, command, provenance)
|
|
121
|
+
stdout, stderr, success = @executor.call(command, chdir: chdir)
|
|
122
|
+
payload = AblationAgentPayload.parse(stdout)
|
|
123
|
+
provenance.model = payload && payload['model']
|
|
124
|
+
_check_stdout, check_stderr, resolved = @executor.call(task.check, chdir: chdir)
|
|
125
|
+
|
|
126
|
+
Result.new(task_id: task.id, condition: condition, resolved: resolved == true,
|
|
127
|
+
total_tokens: payload && AblationAgentPayload.token_total(payload['usage']),
|
|
128
|
+
cost_usd: payload && payload['total_cost_usd'],
|
|
129
|
+
turns: payload && payload['num_turns'],
|
|
130
|
+
duration_ms: payload && payload['duration_ms'],
|
|
131
|
+
error: agent_error(success, payload, stderr) || check_timeout_error(check_stderr),
|
|
132
|
+
provenance: provenance)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def agent_error(success, payload, stderr)
|
|
136
|
+
return nil if success && payload
|
|
137
|
+
|
|
138
|
+
[stderr.to_s.strip, payload ? nil : 'agent printed no JSON'].compact.join('; ')
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# The check's own exit status already drives `resolved`; a normal test
|
|
142
|
+
# failure is not a harness error. A timeout is: {AblationTimedExecutor}
|
|
143
|
+
# marks it with a recognizable stderr message, so it still surfaces as
|
|
144
|
+
# an error (and is counted in the summary) even though `resolved` is
|
|
145
|
+
# also false.
|
|
146
|
+
def check_timeout_error(check_stderr)
|
|
147
|
+
return nil unless check_stderr.to_s.include?('timed out after')
|
|
148
|
+
|
|
149
|
+
"check #{check_stderr.strip}"
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def preflight_error(condition, chdir, command)
|
|
153
|
+
return nil if @woods_probe.call(condition, chdir, command)
|
|
154
|
+
|
|
155
|
+
"Woods availability check failed for condition #{condition}"
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
def failed_result(task, condition, provenance, error)
|
|
159
|
+
Result.new(task_id: task.id, condition: condition, resolved: false, total_tokens: nil, cost_usd: nil,
|
|
160
|
+
turns: nil, duration_ms: nil, error: error, provenance: provenance)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def blank_provenance(condition, baseline_sha)
|
|
164
|
+
AblationProvenance.new(agent_command: agent_command(condition), model: nil, config: nil,
|
|
165
|
+
woods_generation: nil, baseline_sha: baseline_sha)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def agent_command(condition)
|
|
169
|
+
condition == :on ? @task_set.agent_on : @task_set.agent_off
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Woods
|
|
4
|
+
module Evaluation
|
|
5
|
+
# Aggregates {AblationRunner::Result} values into a per-condition summary
|
|
6
|
+
# (resolution rate, mean tokens/cost/turns, task and error counts) plus
|
|
7
|
+
# the on-minus-off delta (#280).
|
|
8
|
+
class AblationSummary
|
|
9
|
+
DELTA_METRICS = %i[resolution_rate mean_tokens mean_cost_usd mean_turns].freeze
|
|
10
|
+
|
|
11
|
+
# @param results [Array<AblationRunner::Result>]
|
|
12
|
+
# @param conditions [Array<Symbol>] subset of `%i[on off]`
|
|
13
|
+
# @return [Hash{Symbol=>Hash}]
|
|
14
|
+
def self.build(results, conditions)
|
|
15
|
+
new(results, conditions).build
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def initialize(results, conditions)
|
|
19
|
+
@results = results
|
|
20
|
+
@conditions = conditions
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def build
|
|
24
|
+
summary = {}
|
|
25
|
+
@conditions.each { |condition| summary[condition] = condition_summary(condition) }
|
|
26
|
+
summary[:delta] = delta(summary[:on], summary[:off]) if summary.key?(:on) && summary.key?(:off)
|
|
27
|
+
summary
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def condition_summary(condition)
|
|
33
|
+
results = @results.select { |result| result.condition == condition }
|
|
34
|
+
{
|
|
35
|
+
resolution_rate: resolution_rate(results),
|
|
36
|
+
mean_tokens: mean(results.map(&:total_tokens)),
|
|
37
|
+
mean_cost_usd: mean(results.map(&:cost_usd)),
|
|
38
|
+
mean_turns: mean(results.map(&:turns)),
|
|
39
|
+
tasks: results.size,
|
|
40
|
+
errors: results.count(&:error)
|
|
41
|
+
}
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def resolution_rate(results)
|
|
45
|
+
return 0.0 if results.empty?
|
|
46
|
+
|
|
47
|
+
(results.count(&:resolved).to_f / results.size).round(4)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def delta(on, off)
|
|
51
|
+
DELTA_METRICS.to_h do |key|
|
|
52
|
+
value = on[key].nil? || off[key].nil? ? nil : (on[key] - off[key]).round(4)
|
|
53
|
+
[key, value]
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def mean(values)
|
|
58
|
+
present = values.compact
|
|
59
|
+
return nil if present.empty?
|
|
60
|
+
|
|
61
|
+
(present.sum.to_f / present.size).round(4)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'ablation_task_set'
|
|
4
|
+
require_relative 'ablation_runner'
|
|
5
|
+
require_relative 'ablation_report_writer'
|
|
6
|
+
|
|
7
|
+
module Woods
|
|
8
|
+
module Evaluation
|
|
9
|
+
# Entry point for `woods:evaluate:ablation`: loads a task set, applies
|
|
10
|
+
# environment overrides, runs the harness, and writes the report (#280).
|
|
11
|
+
# Kept out of the rake file so the task definition stays a thin
|
|
12
|
+
# delegation and the logic is testable without Rake.
|
|
13
|
+
#
|
|
14
|
+
# File map for `lib/woods/evaluation/ablation_*.rb`: {AblationTaskSet}
|
|
15
|
+
# loads and validates the task set file; {AblationRunner} orchestrates a
|
|
16
|
+
# trial per task per condition; {AblationWorktree} gives each trial its
|
|
17
|
+
# own disposable git checkout; {AblationExecutor} is the default
|
|
18
|
+
# subprocess executor; {AblationTimedExecutor} wraps any executor with
|
|
19
|
+
# the per-call timeout and kill-on-timeout behavior; {AblationProvenance}
|
|
20
|
+
# and {AblationAgentPayload} build a trial's provenance and parse the
|
|
21
|
+
# agent's JSON payload; {AblationSummary} aggregates results into the
|
|
22
|
+
# per-condition and delta summary; {AblationReportWriter} serializes the
|
|
23
|
+
# report and renders the terminal summary; this class wires them
|
|
24
|
+
# together for the rake task.
|
|
25
|
+
class AblationTask
|
|
26
|
+
DEFAULT_TASKS = 'config/eval_ablation.json'
|
|
27
|
+
DEFAULT_OUTPUT = 'tmp/eval_ablation.json'
|
|
28
|
+
|
|
29
|
+
# @param task_set_path [String, nil]
|
|
30
|
+
# @param workdir [String]
|
|
31
|
+
# @return [AblationRunner::Report]
|
|
32
|
+
def self.run(task_set_path: nil, workdir: Dir.pwd)
|
|
33
|
+
new(task_set_path: task_set_path, workdir: workdir).run
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def initialize(task_set_path: nil, workdir: Dir.pwd)
|
|
37
|
+
@task_set_path = task_set_path || ENV.fetch('EVAL_ABLATION_TASKS', DEFAULT_TASKS)
|
|
38
|
+
@output = ENV.fetch('EVAL_ABLATION_OUTPUT', DEFAULT_OUTPUT)
|
|
39
|
+
@workdir = workdir
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# @return [AblationRunner::Report]
|
|
43
|
+
def run
|
|
44
|
+
task_set = load_task_set
|
|
45
|
+
puts "Running #{task_set.tasks.size} task(s) with the index on and off, " \
|
|
46
|
+
'one disposable worktree per trial...'
|
|
47
|
+
report = AblationRunner.new(task_set: task_set, workdir: @workdir).run
|
|
48
|
+
|
|
49
|
+
writer = AblationReportWriter.new(report)
|
|
50
|
+
writer.write(@output)
|
|
51
|
+
puts writer.summary_text
|
|
52
|
+
puts "Report saved to: #{@output}"
|
|
53
|
+
report
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def load_task_set
|
|
59
|
+
task_set = AblationTaskSet.load(@task_set_path)
|
|
60
|
+
task_set.agent_on = ENV['EVAL_AGENT_ON'] if ENV['EVAL_AGENT_ON']
|
|
61
|
+
task_set.agent_off = ENV['EVAL_AGENT_OFF'] if ENV['EVAL_AGENT_OFF']
|
|
62
|
+
task_set
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Woods
|
|
6
|
+
module Evaluation
|
|
7
|
+
# A task set for the agent-level ablation (#280): the same coding tasks
|
|
8
|
+
# run with the Woods index on and off.
|
|
9
|
+
#
|
|
10
|
+
# File shape (schema_version 1):
|
|
11
|
+
#
|
|
12
|
+
# {
|
|
13
|
+
# "schema_version": 1,
|
|
14
|
+
# "agent_on": "claude -p {prompt} --output-format json --mcp-config .mcp.json",
|
|
15
|
+
# "agent_off": "claude -p {prompt} --output-format json --strict-mcp-config",
|
|
16
|
+
# "reset": "git checkout -- . && git clean -fdq", // optional
|
|
17
|
+
# "tasks": [
|
|
18
|
+
# { "id": "...", "prompt": "...", "check": "bin/rspec spec/...", "workdir": "." }
|
|
19
|
+
# ]
|
|
20
|
+
# }
|
|
21
|
+
#
|
|
22
|
+
# `{prompt}` is replaced with the shell-escaped prompt. `check` is any
|
|
23
|
+
# command whose exit status says whether the task was resolved.
|
|
24
|
+
class AblationTaskSet
|
|
25
|
+
SUPPORTED_SCHEMA_VERSIONS = [1].freeze
|
|
26
|
+
|
|
27
|
+
Task = Struct.new(:id, :prompt, :check, :workdir, keyword_init: true)
|
|
28
|
+
|
|
29
|
+
# Named `Definition` rather than `Data`: Ruby 3.2 introduces a
|
|
30
|
+
# top-level `::Data` class, and shadowing it inside this class body is
|
|
31
|
+
# legal on the 3.0 floor but confusing to read.
|
|
32
|
+
Definition = Struct.new(:schema_version, :agent_on, :agent_off, :reset, :tasks, keyword_init: true)
|
|
33
|
+
|
|
34
|
+
class << self
|
|
35
|
+
# @param path [String]
|
|
36
|
+
# @return [Definition]
|
|
37
|
+
# @raise [Woods::Error] on an unreadable file, invalid JSON, or an invalid shape
|
|
38
|
+
def load(path)
|
|
39
|
+
raw = JSON.parse(File.read(path.to_s, encoding: 'UTF-8'))
|
|
40
|
+
version = raw['schema_version']
|
|
41
|
+
unless SUPPORTED_SCHEMA_VERSIONS.include?(version)
|
|
42
|
+
raise Woods::Error, "Unsupported ablation schema_version #{version.inspect} in #{path}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
Definition.new(
|
|
46
|
+
schema_version: version,
|
|
47
|
+
agent_on: command!(raw, 'agent_on', path),
|
|
48
|
+
agent_off: command!(raw, 'agent_off', path),
|
|
49
|
+
reset: raw['reset'],
|
|
50
|
+
tasks: Array(raw['tasks']).map { |task| build_task(task, path) }
|
|
51
|
+
)
|
|
52
|
+
rescue JSON::ParserError => e
|
|
53
|
+
raise Woods::Error, "Invalid JSON in ablation task set #{path}: #{e.message}"
|
|
54
|
+
rescue Errno::ENOENT => e
|
|
55
|
+
raise Woods::Error, "Ablation task set not found: #{e.message}"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def command!(raw, key, path)
|
|
61
|
+
value = raw[key].to_s
|
|
62
|
+
raise Woods::Error, "#{key} in #{path} must contain {prompt}" unless value.include?('{prompt}')
|
|
63
|
+
|
|
64
|
+
value
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def build_task(task, path)
|
|
68
|
+
%w[id prompt check].each do |key|
|
|
69
|
+
raise Woods::Error, "Task #{task['id'].inspect} in #{path} is missing #{key}" if task[key].to_s.empty?
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
Task.new(id: task['id'], prompt: task['prompt'], check: task['check'], workdir: task['workdir'] || '.')
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'timeout'
|
|
4
|
+
|
|
5
|
+
module Woods
|
|
6
|
+
module Evaluation
|
|
7
|
+
# Bounds every call an ablation trial makes to the same per-call timeout
|
|
8
|
+
# (#280 review): a per-command budget applied independently to each
|
|
9
|
+
# command a trial runs (worktree add/remove, the optional reset, the
|
|
10
|
+
# agent invocation, the check), not one budget shared across the whole
|
|
11
|
+
# trial. `AblationRunner` wraps whatever executor it is given with this
|
|
12
|
+
# class exactly once, so `AblationWorktree` and the runner itself both
|
|
13
|
+
# get the bound for free by calling `@executor.call` as normal.
|
|
14
|
+
#
|
|
15
|
+
# On timeout, `Timeout.timeout` only interrupts the calling thread; a
|
|
16
|
+
# subprocess started by the wrapped executor keeps running unless
|
|
17
|
+
# something kills it. When the wrapped executor exposes its most recent
|
|
18
|
+
# pid (see {AblationExecutor}), this sends TERM, waits briefly, then KILL
|
|
19
|
+
# if the process is still alive, before reporting the call as a timed-out
|
|
20
|
+
# failure. An executor that does not expose a pid (for example a fake
|
|
21
|
+
# executor in a spec) still gets the timeout, just without a process to
|
|
22
|
+
# terminate.
|
|
23
|
+
class AblationTimedExecutor
|
|
24
|
+
TERM_GRACE_SECONDS = 2
|
|
25
|
+
REAP_GRACE_SECONDS = 2
|
|
26
|
+
POLL_INTERVAL = 0.05
|
|
27
|
+
|
|
28
|
+
# @param executor [#call] `call(command, chdir:)` returning `[stdout, stderr, success]`
|
|
29
|
+
# @param timeout [Numeric] seconds allowed per call
|
|
30
|
+
def initialize(executor, timeout:)
|
|
31
|
+
@executor = executor
|
|
32
|
+
@timeout = timeout
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# @return [Array(String, String, Boolean)] stdout, stderr, success
|
|
36
|
+
def call(command, chdir:)
|
|
37
|
+
Timeout.timeout(@timeout) { @executor.call(command, chdir: chdir) }
|
|
38
|
+
rescue Timeout::Error
|
|
39
|
+
['', "timed out after #{@timeout}s#{terminate_process}", false]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
# @return [String] empty, or a note that the pid outlived the reap
|
|
45
|
+
# grace period, appended to the timeout message
|
|
46
|
+
def terminate_process
|
|
47
|
+
pid = executor_pid
|
|
48
|
+
return '' unless pid
|
|
49
|
+
|
|
50
|
+
Process.kill('TERM', pid)
|
|
51
|
+
Process.kill('KILL', pid) unless process_exited?(pid, within: TERM_GRACE_SECONDS)
|
|
52
|
+
reap(pid, within: REAP_GRACE_SECONDS) ? '' : " (pid #{pid} did not reap within #{REAP_GRACE_SECONDS}s)"
|
|
53
|
+
rescue Errno::ESRCH, Errno::ECHILD
|
|
54
|
+
''
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def executor_pid
|
|
58
|
+
@executor.pid if @executor.respond_to?(:pid)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def process_exited?(pid, within:)
|
|
62
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + within
|
|
63
|
+
until Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
64
|
+
begin
|
|
65
|
+
Process.kill(0, pid)
|
|
66
|
+
rescue Errno::ESRCH
|
|
67
|
+
return true
|
|
68
|
+
end
|
|
69
|
+
sleep POLL_INTERVAL
|
|
70
|
+
end
|
|
71
|
+
false
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Polls a non-blocking wait instead of a plain Process.wait (#280
|
|
75
|
+
# review, minor): a pid some other bug left un-reapable must not hang
|
|
76
|
+
# the whole trial forever just because we tried to clean up after it.
|
|
77
|
+
def reap(pid, within:)
|
|
78
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + within
|
|
79
|
+
until Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
80
|
+
reaped_pid, = Process.wait2(pid, Process::WNOHANG)
|
|
81
|
+
return true if reaped_pid
|
|
82
|
+
|
|
83
|
+
sleep POLL_INTERVAL
|
|
84
|
+
end
|
|
85
|
+
false
|
|
86
|
+
rescue Errno::ECHILD
|
|
87
|
+
true
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'tmpdir'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require 'shellwords'
|
|
6
|
+
|
|
7
|
+
module Woods
|
|
8
|
+
module Evaluation
|
|
9
|
+
# Sets up and tears down one disposable git worktree per ablation trial
|
|
10
|
+
# (#280), checked out from a fixed baseline SHA so an on/off pair for
|
|
11
|
+
# the same task starts from the identical commit. The trial never runs
|
|
12
|
+
# in the caller's own working directory, and a reset failure aborts the
|
|
13
|
+
# trial rather than being ignored.
|
|
14
|
+
#
|
|
15
|
+
# @example
|
|
16
|
+
# worktree = AblationWorktree.new(repo_root: '/app', baseline_sha: sha, executor: executor)
|
|
17
|
+
# error = worktree.trial('git checkout -- . && git clean -fdq') { |path| run_agent(path) }
|
|
18
|
+
# error # => nil on success, an error string otherwise (the block never ran)
|
|
19
|
+
class AblationWorktree
|
|
20
|
+
# @param repo_root [String] the repository the trial is checked out from
|
|
21
|
+
# @param baseline_sha [String, nil] the commit every trial checks out
|
|
22
|
+
# @param executor [#call] `call(command, chdir:)` returning `[stdout, stderr, success]`
|
|
23
|
+
def initialize(repo_root:, baseline_sha:, executor:)
|
|
24
|
+
@repo_root = repo_root
|
|
25
|
+
@baseline_sha = baseline_sha
|
|
26
|
+
@executor = executor
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# @param reset_command [String, nil]
|
|
30
|
+
# @yieldparam path [String] the checkout root, only when setup succeeded
|
|
31
|
+
# @return [String, nil] an error message when the checkout or reset
|
|
32
|
+
# failed (the block never ran), else nil
|
|
33
|
+
def trial(reset_command)
|
|
34
|
+
parent = nil
|
|
35
|
+
path = nil
|
|
36
|
+
added = false
|
|
37
|
+
|
|
38
|
+
parent = Dir.mktmpdir('woods-ablation-')
|
|
39
|
+
path = File.join(parent, 'checkout')
|
|
40
|
+
error = add_worktree(path)
|
|
41
|
+
added = error.nil?
|
|
42
|
+
error ||= reset(path, reset_command) if added && reset_command
|
|
43
|
+
yield path if error.nil?
|
|
44
|
+
error
|
|
45
|
+
ensure
|
|
46
|
+
teardown(path) if added
|
|
47
|
+
FileUtils.remove_entry(parent, true) if parent
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
private
|
|
51
|
+
|
|
52
|
+
def add_worktree(path)
|
|
53
|
+
_out, err, ok = run_in_repo("git worktree add --detach #{Shellwords.escape(path)} #{@baseline_sha}")
|
|
54
|
+
ok ? nil : "git worktree add failed: #{err.to_s.strip}"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def reset(path, reset_command)
|
|
58
|
+
_out, err, ok = @executor.call(reset_command, chdir: path)
|
|
59
|
+
ok ? nil : "reset failed: #{err.to_s.strip}"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def teardown(path)
|
|
63
|
+
run_in_repo("git worktree remove --force #{Shellwords.escape(path)}")
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def run_in_repo(command)
|
|
67
|
+
@executor.call(command, chdir: @repo_root)
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
|
|
5
|
+
module Woods
|
|
6
|
+
module Evaluation
|
|
7
|
+
# Loads a versioned baseline/thresholds file for `woods:evaluate`.
|
|
8
|
+
#
|
|
9
|
+
# File shape (schema_version 1):
|
|
10
|
+
#
|
|
11
|
+
# {
|
|
12
|
+
# "schema_version": 1,
|
|
13
|
+
# "query_set": "config/eval_queries.json",
|
|
14
|
+
# "captured_at": "2026-08-20T00:00:00Z", // or null if never captured
|
|
15
|
+
# "thresholds": {
|
|
16
|
+
# "mean_precision_at5": 0.0,
|
|
17
|
+
# "mean_recall": 0.0,
|
|
18
|
+
# "mean_mrr": 0.0
|
|
19
|
+
# },
|
|
20
|
+
# "notes": "free text"
|
|
21
|
+
# }
|
|
22
|
+
#
|
|
23
|
+
# `thresholds` keys must match {Evaluator::EvaluationReport}'s +aggregates+
|
|
24
|
+
# keys (e.g. "mean_recall", not "recall") — the evaluator compares them
|
|
25
|
+
# directly against computed aggregates.
|
|
26
|
+
#
|
|
27
|
+
# The checked-in example under spec/fixtures/evaluation_baseline.example.json
|
|
28
|
+
# is a format fixture only: its thresholds are 0.0 placeholders, not a
|
|
29
|
+
# captured v2 corpus baseline. Capturing the real baseline — running the
|
|
30
|
+
# harness against the pinned v2 corpus and recording the resulting scores
|
|
31
|
+
# as thresholds — is future work.
|
|
32
|
+
class Baseline
|
|
33
|
+
SUPPORTED_SCHEMA_VERSIONS = [1].freeze
|
|
34
|
+
|
|
35
|
+
Data = Struct.new(:schema_version, :query_set, :captured_at, :thresholds, :notes, keyword_init: true)
|
|
36
|
+
|
|
37
|
+
class << self
|
|
38
|
+
# @param path [String] path to a baseline JSON file
|
|
39
|
+
# @return [Data]
|
|
40
|
+
# @raise [Woods::Error] on an unreadable file, invalid JSON, or an
|
|
41
|
+
# unsupported schema_version
|
|
42
|
+
def load(path)
|
|
43
|
+
raw = JSON.parse(File.read(path.to_s, encoding: 'UTF-8'))
|
|
44
|
+
version = raw['schema_version']
|
|
45
|
+
unless SUPPORTED_SCHEMA_VERSIONS.include?(version)
|
|
46
|
+
raise Woods::Error, "Unsupported baseline schema_version #{version.inspect} in #{path}"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
Data.new(
|
|
50
|
+
schema_version: version,
|
|
51
|
+
query_set: raw['query_set'],
|
|
52
|
+
captured_at: raw['captured_at'],
|
|
53
|
+
thresholds: (raw['thresholds'] || {}).transform_keys(&:to_sym),
|
|
54
|
+
notes: raw['notes']
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative '../storage_identity'
|
|
4
|
+
|
|
3
5
|
module Woods
|
|
4
6
|
module Evaluation
|
|
5
7
|
# Runs simple baseline strategies for comparison against the full
|
|
@@ -51,6 +53,12 @@ module Woods
|
|
|
51
53
|
|
|
52
54
|
private
|
|
53
55
|
|
|
56
|
+
# Evaluation ground truth uses public names, not vector/metadata storage keys.
|
|
57
|
+
# Coexisting types count once because the evaluation contract is name-based.
|
|
58
|
+
def public_identifiers
|
|
59
|
+
@metadata_store.all_identifiers.map { |id| StorageIdentity.identifier(id) }.uniq
|
|
60
|
+
end
|
|
61
|
+
|
|
54
62
|
# Grep strategy: substring match on unit identifiers.
|
|
55
63
|
#
|
|
56
64
|
# Extracts words from the query and matches identifiers that contain
|
|
@@ -60,7 +68,7 @@ module Woods
|
|
|
60
68
|
# @param limit [Integer] Max results
|
|
61
69
|
# @return [Array<String>]
|
|
62
70
|
def run_grep(query, limit)
|
|
63
|
-
all_ids =
|
|
71
|
+
all_ids = public_identifiers
|
|
64
72
|
keywords = extract_keywords(query)
|
|
65
73
|
|
|
66
74
|
return all_ids.first(limit) if keywords.empty?
|
|
@@ -85,7 +93,7 @@ module Woods
|
|
|
85
93
|
# @return [Array<String>]
|
|
86
94
|
def run_random(_query, limit)
|
|
87
95
|
@random_mutex.synchronize do
|
|
88
|
-
|
|
96
|
+
public_identifiers.sample(limit, random: @random)
|
|
89
97
|
end
|
|
90
98
|
end
|
|
91
99
|
|
|
@@ -96,7 +104,7 @@ module Woods
|
|
|
96
104
|
# @param limit [Integer] Max results
|
|
97
105
|
# @return [Array<String>]
|
|
98
106
|
def run_file_level(query, limit)
|
|
99
|
-
all_ids =
|
|
107
|
+
all_ids = public_identifiers
|
|
100
108
|
keywords = extract_keywords(query)
|
|
101
109
|
|
|
102
110
|
return all_ids.first(limit) if keywords.empty?
|