llm-experiment 0.1.0
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 +7 -0
- data/CHANGELOG.md +40 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/LICENSE.txt +21 -0
- data/README.md +190 -0
- data/exe/llmx +6 -0
- data/lib/llm-experiment.rb +3 -0
- data/lib/llm_experiment/auth.rb +143 -0
- data/lib/llm_experiment/cleaner.rb +183 -0
- data/lib/llm_experiment/cli/build_command.rb +37 -0
- data/lib/llm_experiment/cli/clean_command.rb +42 -0
- data/lib/llm_experiment/cli/doctor_command.rb +30 -0
- data/lib/llm_experiment/cli/login_command.rb +25 -0
- data/lib/llm_experiment/cli/metrics_command.rb +32 -0
- data/lib/llm_experiment/cli/new_command.rb +21 -0
- data/lib/llm_experiment/cli/parse_command.rb +77 -0
- data/lib/llm_experiment/cli/run_command.rb +74 -0
- data/lib/llm_experiment/cli/sanitize_command.rb +34 -0
- data/lib/llm_experiment/cli/shell_command.rb +35 -0
- data/lib/llm_experiment/cli/status_command.rb +69 -0
- data/lib/llm_experiment/cli/version_command.rb +19 -0
- data/lib/llm_experiment/cli.rb +120 -0
- data/lib/llm_experiment/container.rb +178 -0
- data/lib/llm_experiment/doctor.rb +126 -0
- data/lib/llm_experiment/experiment.rb +163 -0
- data/lib/llm_experiment/grid.rb +117 -0
- data/lib/llm_experiment/image_builder/app.rb +267 -0
- data/lib/llm_experiment/image_builder/base.rb +64 -0
- data/lib/llm_experiment/metrics_report.rb +138 -0
- data/lib/llm_experiment/pins.rb +22 -0
- data/lib/llm_experiment/sanitizer.rb +144 -0
- data/lib/llm_experiment/scaffold.rb +43 -0
- data/lib/llm_experiment/shell.rb +60 -0
- data/lib/llm_experiment/stats.rb +69 -0
- data/lib/llm_experiment/transcript/claude.rb +104 -0
- data/lib/llm_experiment/transcript/codex.rb +96 -0
- data/lib/llm_experiment/transcript/hermeticity.rb +35 -0
- data/lib/llm_experiment/transcript/parser.rb +105 -0
- data/lib/llm_experiment/transcript.rb +27 -0
- data/lib/llm_experiment/trial.rb +204 -0
- data/lib/llm_experiment/version.rb +5 -0
- data/lib/llm_experiment.rb +70 -0
- data/templates/README.md.erb +26 -0
- data/templates/base.Containerfile +120 -0
- data/templates/experiment.yml.erb +30 -0
- data/templates/gitignore +3 -0
- data/templates/runner.rb +305 -0
- metadata +92 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module LLMExperiment
|
|
6
|
+
# Wraps the Apple `container` CLI. Every invocation of the CLI in the gem
|
|
7
|
+
# goes through here, so its quirks are handled in exactly one place.
|
|
8
|
+
class Container
|
|
9
|
+
def initialize(shell: Shell)
|
|
10
|
+
@shell = shell
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def cli_version
|
|
14
|
+
out = @shell.capture("container", "--version", allow_failure: true).strip
|
|
15
|
+
out.empty? ? nil : out
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def require_cli!
|
|
19
|
+
cli_version or raise ContainerError,
|
|
20
|
+
"Apple `container` CLI not found. Install it, then run `container system start`."
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def system_running?
|
|
24
|
+
@shell.capture("container", "system", "status", allow_failure: true)
|
|
25
|
+
.include?("apiserver is running")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def ensure_system!
|
|
29
|
+
require_cli!
|
|
30
|
+
return true if system_running?
|
|
31
|
+
|
|
32
|
+
@shell.log "starting container system"
|
|
33
|
+
@shell.sh("container", "system", "start")
|
|
34
|
+
true
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def builder_status
|
|
38
|
+
ensure_system!
|
|
39
|
+
@shell.capture("container", "builder", "status", allow_failure: true)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# The builder is a separate VM with its own envelope (2 CPUs / 2 GB by
|
|
43
|
+
# default), and a starved builder dies mid-build with no error at all.
|
|
44
|
+
# `container builder status` prints one row:
|
|
45
|
+
#
|
|
46
|
+
# ID IMAGE STATE ADDR CPUS MEMORY
|
|
47
|
+
# buildkit ...:0.6.1 running 192.168.64.3 6 8192 MB
|
|
48
|
+
#
|
|
49
|
+
# Memory is reported in MB and configured in G, so the two are compared as
|
|
50
|
+
# numbers rather than strings.
|
|
51
|
+
BUILDER_ROW = /^buildkit\s.*\brunning\s+\S+\s+(\d+)\s+(\d+\s*[KMGT]?B?)/i
|
|
52
|
+
|
|
53
|
+
# "8G", "8g", "8GB", "8192 MB" and "8192MB" all mean the same envelope.
|
|
54
|
+
# A bare number is read as MB, which is how the CLI reports it.
|
|
55
|
+
def self.megabytes(text)
|
|
56
|
+
value, unit = text.to_s.strip.match(/\A(\d+)\s*([KMGT]?)B?\z/i)&.captures
|
|
57
|
+
return 0 unless value
|
|
58
|
+
|
|
59
|
+
case unit.to_s.upcase
|
|
60
|
+
when "G" then value.to_i * 1024
|
|
61
|
+
when "T" then value.to_i * 1024 * 1024
|
|
62
|
+
when "K" then value.to_i / 1024
|
|
63
|
+
else value.to_i
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# A starved builder dies mid-build with no error, so an existing builder is
|
|
68
|
+
# only reused when it meets the whole envelope. Checking CPUs alone accepted
|
|
69
|
+
# a builder with 6 CPUs and 2 GB.
|
|
70
|
+
def ensure_builder!
|
|
71
|
+
if (row = builder_status.match(BUILDER_ROW))
|
|
72
|
+
cpus = row[1].to_i
|
|
73
|
+
memory_mb = self.class.megabytes(row[2])
|
|
74
|
+
want_cpus = LLMExperiment.builder_cpus.to_i
|
|
75
|
+
want_mb = self.class.megabytes(LLMExperiment.builder_memory)
|
|
76
|
+
return true if cpus >= want_cpus && memory_mb >= want_mb
|
|
77
|
+
|
|
78
|
+
@shell.log "builder has #{cpus} CPUs and #{memory_mb} MB; " \
|
|
79
|
+
"restarting with #{want_cpus} CPUs and #{LLMExperiment.builder_memory}"
|
|
80
|
+
@shell.sh("container", "builder", "stop", allow_failure: true)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
@shell.log "starting image builder (#{LLMExperiment.builder_cpus} CPUs, #{LLMExperiment.builder_memory})"
|
|
84
|
+
@shell.sh("container", "builder", "start",
|
|
85
|
+
"--cpus", LLMExperiment.builder_cpus,
|
|
86
|
+
"--memory", LLMExperiment.builder_memory)
|
|
87
|
+
true
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# The subcommand is `container image list`; `container images` fails
|
|
91
|
+
# confusingly, which is why nothing else in the gem calls it directly.
|
|
92
|
+
def images
|
|
93
|
+
@shell.capture("container", "image", "list", allow_failure: true)
|
|
94
|
+
.lines.drop(1)
|
|
95
|
+
.filter_map do |line|
|
|
96
|
+
repo, tag = line.split
|
|
97
|
+
"#{repo}:#{tag}" if repo && tag
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def image?(name)
|
|
102
|
+
repo, tag = name.split(":", 2)
|
|
103
|
+
tag ||= "latest"
|
|
104
|
+
images.include?("#{repo}:#{tag}")
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def build(tag:, file:, context:, build_args: {}, no_cache: false)
|
|
108
|
+
cmd = ["container", "build", "--tag", tag, "--file", file]
|
|
109
|
+
build_args.each { |key, value| cmd += ["--build-arg", "#{key}=#{value}"] }
|
|
110
|
+
cmd << "--no-cache" if no_cache
|
|
111
|
+
cmd << context
|
|
112
|
+
@shell.sh(*cmd)
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def delete_image(name)
|
|
116
|
+
@shell.sh("container", "image", "delete", name, allow_failure: true)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def containers_all
|
|
120
|
+
@shell.capture("container", "ls", "-a", allow_failure: true)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def free_gb
|
|
124
|
+
line = @shell.capture("df", "-g", "/System/Volumes/Data", allow_failure: true).lines.last.to_s
|
|
125
|
+
line.split[3].to_i
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Running out of disk mid-build does not fail cleanly: the build dies with
|
|
129
|
+
# no message, and on a truly full disk even writing the error fails.
|
|
130
|
+
def ensure_disk!(need_gb: LLMExperiment.min_free_gb)
|
|
131
|
+
free = free_gb
|
|
132
|
+
return true if free >= need_gb
|
|
133
|
+
|
|
134
|
+
raise ContainerError, <<~MSG
|
|
135
|
+
Only #{free} GB free; this build needs about #{need_gb} GB.
|
|
136
|
+
Most of that is usually the BuildKit builder VM rather than any image:
|
|
137
|
+
llmx clean report what is on disk, delete nothing
|
|
138
|
+
llmx clean --builder drop the builder cache, which is regenerable
|
|
139
|
+
MSG
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Arguments common to every trial container: resource envelope plus the
|
|
143
|
+
# credential store mount. The store is a seed, not a home — runners copy
|
|
144
|
+
# credentials out of it and never write back.
|
|
145
|
+
def run_args(memory:, cpus:, mount_auth: true)
|
|
146
|
+
args = ["--memory", memory, "--cpus", cpus.to_s]
|
|
147
|
+
if mount_auth
|
|
148
|
+
%w[claude codex opencode].each do |agent|
|
|
149
|
+
FileUtils.mkdir_p(File.join(LLMExperiment.auth_dir, agent))
|
|
150
|
+
end
|
|
151
|
+
home = "/home/#{LLMExperiment.agent_user}"
|
|
152
|
+
args += ["--volume", "#{LLMExperiment.auth_dir}:#{home}/.agent-auth"]
|
|
153
|
+
args += ["--env", "CLAUDE_CONFIG_DIR=#{home}/.agent-auth/claude"]
|
|
154
|
+
args += ["--env", "CODEX_HOME=#{home}/.agent-auth/codex"]
|
|
155
|
+
args += ["--env", "LLMX_OPENCODE_AUTH=#{home}/.agent-auth/opencode"]
|
|
156
|
+
end
|
|
157
|
+
args
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# The full argv for one containerized command. Always through a login
|
|
161
|
+
# shell: `container run` does not apply the image's ENV PATH, and the
|
|
162
|
+
# rubies are mise shims.
|
|
163
|
+
def run_argv(image, script, memory:, cpus:, mount_auth: true,
|
|
164
|
+
volumes: [], env: {}, workdir: nil, interactive: false)
|
|
165
|
+
argv = ["container", "run", "--rm"]
|
|
166
|
+
argv += ["--interactive", "--tty"] if interactive
|
|
167
|
+
argv += run_args(memory: memory, cpus: cpus, mount_auth: mount_auth)
|
|
168
|
+
volumes.each { |volume| argv += ["--volume", volume] }
|
|
169
|
+
env.each { |key, value| argv += ["--env", "#{key}=#{value}"] }
|
|
170
|
+
argv += ["--workdir", workdir] if workdir
|
|
171
|
+
argv + [image, "bash", "-lc", script]
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def run(image, script, **options)
|
|
175
|
+
@shell.sh(*run_argv(image, script, **options))
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLMExperiment
|
|
4
|
+
# Preflight. Every check returns what is wrong AND what to type next,
|
|
5
|
+
# because each one encodes a failure that used to cost an afternoon.
|
|
6
|
+
class Doctor
|
|
7
|
+
Check = Data.define(:name, :status, :detail, :hint)
|
|
8
|
+
|
|
9
|
+
def initialize(container: Container.new)
|
|
10
|
+
@container = container
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def checks(experiment = nil)
|
|
14
|
+
list = [cli_check]
|
|
15
|
+
# Without the CLI nothing else is answerable.
|
|
16
|
+
return list if list.first.status == :fail
|
|
17
|
+
|
|
18
|
+
list += [system_check, builder_check, disk_check, auth_check]
|
|
19
|
+
list += experiment_checks(experiment) if experiment
|
|
20
|
+
list
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def ok?(experiment = nil)
|
|
24
|
+
checks(experiment).none? { |c| c.status == :fail }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def cli_check
|
|
30
|
+
version = @container.cli_version
|
|
31
|
+
if version
|
|
32
|
+
Check.new(name: "container CLI", status: :ok, detail: version, hint: nil)
|
|
33
|
+
else
|
|
34
|
+
Check.new(name: "container CLI", status: :fail,
|
|
35
|
+
detail: "Apple `container` CLI not found",
|
|
36
|
+
hint: "install it, then run `container system start`")
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def system_check
|
|
41
|
+
if @container.system_running?
|
|
42
|
+
Check.new(name: "apiserver", status: :ok, detail: "running", hint: nil)
|
|
43
|
+
else
|
|
44
|
+
Check.new(name: "apiserver", status: :fail, detail: "not running",
|
|
45
|
+
hint: "container system start")
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def builder_check
|
|
50
|
+
status = @container.builder_status
|
|
51
|
+
if status.match?(/^buildkit\s.*\brunning\b/)
|
|
52
|
+
cpus = status[/\brunning\s+\S+\s+(\d+)/, 1].to_i
|
|
53
|
+
if cpus >= LLMExperiment.builder_cpus.to_i
|
|
54
|
+
Check.new(name: "builder", status: :ok, detail: "running with #{cpus} CPUs", hint: nil)
|
|
55
|
+
else
|
|
56
|
+
Check.new(name: "builder", status: :warn,
|
|
57
|
+
detail: "running with only #{cpus} CPUs; native gem builds will die silently",
|
|
58
|
+
hint: "llmx build will restart it with #{LLMExperiment.builder_cpus} CPUs")
|
|
59
|
+
end
|
|
60
|
+
else
|
|
61
|
+
Check.new(name: "builder", status: :warn, detail: "not running",
|
|
62
|
+
hint: "started automatically on first build")
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def disk_check
|
|
67
|
+
free = @container.free_gb
|
|
68
|
+
if free >= LLMExperiment.min_free_gb
|
|
69
|
+
Check.new(name: "disk space", status: :ok, detail: "#{free} GB free", hint: nil)
|
|
70
|
+
else
|
|
71
|
+
Check.new(name: "disk space", status: :fail,
|
|
72
|
+
detail: "#{free} GB free; app image builds need ~#{LLMExperiment.min_free_gb} GB and die without an error message when disk runs out",
|
|
73
|
+
hint: "llmx clean --builder")
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def auth_check
|
|
78
|
+
# Dir.children, not a glob: the credential files are dotfiles
|
|
79
|
+
# (.credentials.json, .claude.json) and a "*" glob misses them.
|
|
80
|
+
seeded = %w[claude codex opencode].select do |agent|
|
|
81
|
+
dir = File.join(LLMExperiment.auth_dir, agent)
|
|
82
|
+
Dir.exist?(dir) && Dir.children(dir).any?
|
|
83
|
+
end
|
|
84
|
+
if seeded.any?
|
|
85
|
+
Check.new(name: "auth store", status: :ok,
|
|
86
|
+
detail: "credentials for: #{seeded.join(", ")}", hint: nil)
|
|
87
|
+
else
|
|
88
|
+
Check.new(name: "auth store", status: :warn,
|
|
89
|
+
detail: "#{LLMExperiment.auth_dir} is empty", hint: "llmx login")
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def experiment_checks(experiment)
|
|
94
|
+
checks = []
|
|
95
|
+
checks << image_check("base image", LLMExperiment.base_image, "llmx build base")
|
|
96
|
+
experiment.apps.each_value do |app|
|
|
97
|
+
checks << image_check("app image #{app.key}", app.image, "llmx build app #{app.key}")
|
|
98
|
+
next unless app.repo_path.to_s.empty?
|
|
99
|
+
|
|
100
|
+
checks << Check.new(name: "checkout #{app.key}", status: :warn,
|
|
101
|
+
detail: "LLMX_APP_#{app.key.upcase} is not set",
|
|
102
|
+
hint: "needed only to build the app image")
|
|
103
|
+
end
|
|
104
|
+
missing = experiment.tasks.flat_map do |task|
|
|
105
|
+
experiment.conditions.reject { |c| File.exist?(experiment.prompt_path(task, c)) }
|
|
106
|
+
.map { |c| "#{task.id}/#{c}" }
|
|
107
|
+
end
|
|
108
|
+
checks << if missing.empty?
|
|
109
|
+
Check.new(name: "prompts", status: :ok, detail: "all rendered", hint: nil)
|
|
110
|
+
else
|
|
111
|
+
Check.new(name: "prompts", status: :warn,
|
|
112
|
+
detail: "missing: #{missing.first(5).join(", ")}#{missing.size > 5 ? " (+#{missing.size - 5})" : ""}",
|
|
113
|
+
hint: "render your prompts before `llmx run`")
|
|
114
|
+
end
|
|
115
|
+
checks
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def image_check(name, image, hint)
|
|
119
|
+
if @container.image?(image)
|
|
120
|
+
Check.new(name: name, status: :ok, detail: image, hint: nil)
|
|
121
|
+
else
|
|
122
|
+
Check.new(name: name, status: :warn, detail: "#{image} not built", hint: hint)
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "psych"
|
|
4
|
+
|
|
5
|
+
module LLMExperiment
|
|
6
|
+
App = Data.define(:key, :ruby, :bundler, :database, :test_command, :db_prepare,
|
|
7
|
+
:suite_ran_pattern, :publish_transcripts, :branch_prefix, :neutralize) do
|
|
8
|
+
# Host checkout paths never live in experiment.yml; they identify the
|
|
9
|
+
# machine and belong in the environment (.apps.local convention).
|
|
10
|
+
def repo_path
|
|
11
|
+
ENV["LLMX_APP_#{key.upcase}"]
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def image
|
|
15
|
+
"llmx-app-#{key}:latest"
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
Task = Data.define(:id, :app, :test_file, :impl_files) do
|
|
20
|
+
def branch
|
|
21
|
+
"trial/#{id}"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# experiment.yml, loaded and validated. Unknown keys are errors: a typoed
|
|
26
|
+
# option that silently does nothing is how experiments measure the wrong thing.
|
|
27
|
+
class Experiment
|
|
28
|
+
TOP_KEYS = %w[name question pins agents conditions trial apps tasks].freeze
|
|
29
|
+
APP_KEYS = %w[ruby bundler database test_command db_prepare suite_ran_pattern
|
|
30
|
+
publish_transcripts branch_prefix neutralize].freeze
|
|
31
|
+
TASK_KEYS = %w[id app test_file impl_files].freeze
|
|
32
|
+
TRIAL_KEYS = %w[timeout_seconds memory cpus].freeze
|
|
33
|
+
AGENT_KEYS = %w[model].freeze
|
|
34
|
+
KNOWN_AGENTS = %w[claude codex].freeze
|
|
35
|
+
DATABASES = %w[sqlite3 postgresql].freeze
|
|
36
|
+
DEFAULT_SUITE_RAN = '\d+ runs?, \d+ assertions?'
|
|
37
|
+
|
|
38
|
+
attr_reader :root, :name, :question, :pins, :agents, :conditions,
|
|
39
|
+
:apps, :tasks, :timeout_seconds, :memory, :cpus
|
|
40
|
+
|
|
41
|
+
def self.find(start = Dir.pwd)
|
|
42
|
+
dir = File.expand_path(start)
|
|
43
|
+
until File.exist?(File.join(dir, "experiment.yml"))
|
|
44
|
+
parent = File.dirname(dir)
|
|
45
|
+
raise ConfigError, "no experiment.yml found in #{start} or any parent" if parent == dir
|
|
46
|
+
|
|
47
|
+
dir = parent
|
|
48
|
+
end
|
|
49
|
+
load(dir)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def self.load(dir)
|
|
53
|
+
path = File.join(dir, "experiment.yml")
|
|
54
|
+
raise ConfigError, "missing #{path}" unless File.exist?(path)
|
|
55
|
+
|
|
56
|
+
new(Psych.safe_load(File.read(path)) || {}, root: File.expand_path(dir))
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def initialize(data, root:)
|
|
60
|
+
@root = root
|
|
61
|
+
reject_unknown!("experiment.yml", data, TOP_KEYS)
|
|
62
|
+
@name = data["name"] or raise ConfigError, "experiment.yml needs a name"
|
|
63
|
+
@question = data["question"].to_s
|
|
64
|
+
@pins = Pins.resolve(data["pins"] || {})
|
|
65
|
+
@agents = parse_agents(data["agents"] || {})
|
|
66
|
+
@conditions = Array(data["conditions"]).map(&:to_s)
|
|
67
|
+
raise ConfigError, "conditions must not be empty" if @conditions.empty?
|
|
68
|
+
|
|
69
|
+
trial = data["trial"] || {}
|
|
70
|
+
reject_unknown!("trial", trial, TRIAL_KEYS)
|
|
71
|
+
@timeout_seconds = (trial["timeout_seconds"] || 900).to_i
|
|
72
|
+
@memory = trial["memory"] || LLMExperiment.default_memory
|
|
73
|
+
@cpus = (trial["cpus"] || LLMExperiment.default_cpus).to_s
|
|
74
|
+
|
|
75
|
+
@apps = parse_apps(data["apps"] || {})
|
|
76
|
+
@tasks = parse_tasks(data["tasks"] || [])
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def app(key)
|
|
80
|
+
@apps.fetch(key) { raise ConfigError, "unknown app: #{key}" }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def task(id)
|
|
84
|
+
@tasks.find { |t| t.id == id } or raise ConfigError, "unknown task: #{id}"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def prompt_path(task, condition)
|
|
88
|
+
File.join(root, "prompts", task.app, task.id, "#{condition}.txt")
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def results_raw_dir = File.join(root, "results-raw")
|
|
92
|
+
def results_dir = File.join(root, "results")
|
|
93
|
+
|
|
94
|
+
def trial_dir(task_id, agent, condition, stamp)
|
|
95
|
+
File.join(results_raw_dir, task_id, agent, condition, stamp)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
private
|
|
99
|
+
|
|
100
|
+
def reject_unknown!(where, hash, known)
|
|
101
|
+
unknown = hash.keys - known
|
|
102
|
+
raise ConfigError, "unknown keys in #{where}: #{unknown.join(", ")}" if unknown.any?
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def parse_agents(data)
|
|
106
|
+
raise ConfigError, "agents must not be empty" if data.empty?
|
|
107
|
+
|
|
108
|
+
unknown = data.keys - KNOWN_AGENTS
|
|
109
|
+
raise ConfigError, "unknown agents: #{unknown.join(", ")} (supported: #{KNOWN_AGENTS.join(", ")})" if unknown.any?
|
|
110
|
+
|
|
111
|
+
data.to_h do |key, value|
|
|
112
|
+
value ||= {}
|
|
113
|
+
reject_unknown!("agents.#{key}", value, AGENT_KEYS)
|
|
114
|
+
[key, { "model" => value["model"] }]
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def parse_apps(data)
|
|
119
|
+
raise ConfigError, "apps must not be empty" if data.empty?
|
|
120
|
+
|
|
121
|
+
data.to_h do |key, value|
|
|
122
|
+
reject_unknown!("apps.#{key}", value, APP_KEYS)
|
|
123
|
+
unless DATABASES.include?(value["database"])
|
|
124
|
+
raise ConfigError,
|
|
125
|
+
"apps.#{key}: database must be one of #{DATABASES.join(", ")}"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
[key, App.new(
|
|
129
|
+
key: key,
|
|
130
|
+
ruby: value.fetch("ruby") { raise ConfigError, "apps.#{key} needs ruby" }.to_s,
|
|
131
|
+
bundler: (value["bundler"] || "default").to_s,
|
|
132
|
+
database: value["database"],
|
|
133
|
+
test_command: value["test_command"] || "bin/rails test",
|
|
134
|
+
db_prepare: value["db_prepare"] || "bin/rails db:test:prepare",
|
|
135
|
+
suite_ran_pattern: value["suite_ran_pattern"] || DEFAULT_SUITE_RAN,
|
|
136
|
+
publish_transcripts: value["publish_transcripts"] == true,
|
|
137
|
+
branch_prefix: value.fetch("branch_prefix") { raise ConfigError, "apps.#{key} needs branch_prefix" },
|
|
138
|
+
neutralize: Array(value["neutralize"])
|
|
139
|
+
)]
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def parse_tasks(data)
|
|
144
|
+
raise ConfigError, "tasks must not be empty" if data.empty?
|
|
145
|
+
|
|
146
|
+
ids = data.map { |t| t["id"] }
|
|
147
|
+
dupes = ids.tally.select { |_, n| n > 1 }.keys
|
|
148
|
+
raise ConfigError, "duplicate task ids: #{dupes.join(", ")}" if dupes.any?
|
|
149
|
+
|
|
150
|
+
data.map do |value|
|
|
151
|
+
reject_unknown!("task #{value["id"]}", value, TASK_KEYS)
|
|
152
|
+
raise ConfigError, "task #{value["id"]}: unknown app #{value["app"]}" unless @apps.key?(value["app"])
|
|
153
|
+
|
|
154
|
+
Task.new(
|
|
155
|
+
id: value.fetch("id"),
|
|
156
|
+
app: value.fetch("app"),
|
|
157
|
+
test_file: value.fetch("test_file") { raise ConfigError, "task #{value["id"]} needs test_file" },
|
|
158
|
+
impl_files: Array(value["impl_files"])
|
|
159
|
+
)
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module LLMExperiment
|
|
4
|
+
# The grid of trials: every task x every agent x every condition.
|
|
5
|
+
#
|
|
6
|
+
# Order is load-bearing.
|
|
7
|
+
#
|
|
8
|
+
# Blocked by task, because the comparison is *within* a task: all of one
|
|
9
|
+
# task's cells run close together, so a slow patch of machine time lands on
|
|
10
|
+
# every condition of that task rather than on one of them.
|
|
11
|
+
#
|
|
12
|
+
# Condition order rotates with the task index, so no condition always runs
|
|
13
|
+
# first and none always runs last.
|
|
14
|
+
#
|
|
15
|
+
# Sequential on purpose. Two trials at once share the machine and contaminate
|
|
16
|
+
# wall_seconds — that happened in the source experiment and made a set of
|
|
17
|
+
# timings unusable. Never add parallelism here.
|
|
18
|
+
#
|
|
19
|
+
# Resumable, because a cell that already has a meta.json is done and is
|
|
20
|
+
# skipped, and because one bad cell must not cost the other 89: a failure is
|
|
21
|
+
# recorded and the run carries on.
|
|
22
|
+
class Grid
|
|
23
|
+
Result = Data.define(:total, :failures) do
|
|
24
|
+
def ran = total - failures.size
|
|
25
|
+
def ok? = failures.empty?
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def initialize(experiment:, container: nil)
|
|
29
|
+
@experiment = experiment
|
|
30
|
+
@container = container
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def plan(app: nil, agent: nil, task: nil)
|
|
34
|
+
tasks = filtered_tasks(app: app, task: task)
|
|
35
|
+
agents = filtered_agents(agent)
|
|
36
|
+
conditions = @experiment.conditions
|
|
37
|
+
|
|
38
|
+
tasks.each_with_index.flat_map do |subject, index|
|
|
39
|
+
ordered = conditions.rotate(index % conditions.size)
|
|
40
|
+
agents.flat_map do |name|
|
|
41
|
+
ordered.map { |condition| { task: subject.id, agent: name, condition: condition } }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# A cell is done once it has at least one run that got as far as writing
|
|
47
|
+
# meta.json. Anything earlier than that is an aborted attempt, not a result.
|
|
48
|
+
def done?(task:, agent:, condition:)
|
|
49
|
+
Dir.glob(File.join(@experiment.results_raw_dir, task, agent, condition, "*", "meta.json")).any?
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def pending(**filters)
|
|
53
|
+
plan(**filters).reject { |cell| done?(**cell) }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def status(**filters)
|
|
57
|
+
plan(**filters).map { |cell| cell.merge(done: done?(**cell)) }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def run(app: nil, agent: nil, task: nil, redo_done: false, dry_run: false)
|
|
61
|
+
filters = { app: app, agent: agent, task: task }
|
|
62
|
+
cells = redo_done ? plan(**filters) : pending(**filters)
|
|
63
|
+
failures = []
|
|
64
|
+
|
|
65
|
+
# One trial at a time. See the note on this class.
|
|
66
|
+
cells.each_with_index do |cell, index|
|
|
67
|
+
yield(index + 1, cells.size, cell) if block_given?
|
|
68
|
+
# A grid dry run is about the order and the count. To inspect one
|
|
69
|
+
# command and its prompt, dry-run that single trial instead.
|
|
70
|
+
next if dry_run
|
|
71
|
+
|
|
72
|
+
begin
|
|
73
|
+
trial(cell).run
|
|
74
|
+
rescue StandardError => e
|
|
75
|
+
# Record and carry on: the grid is resumable, so a failed cell is
|
|
76
|
+
# simply re-run later, and aborting here would waste every cell after it.
|
|
77
|
+
failures << cell.merge(error: e.message)
|
|
78
|
+
Shell.log "FAILED: #{label(cell)}: #{e.message.lines.first.to_s.strip}"
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
Result.new(total: cells.size, failures: failures)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def label(cell) = format("%s / %s / %s", cell[:task], cell[:agent], cell[:condition])
|
|
86
|
+
|
|
87
|
+
private
|
|
88
|
+
|
|
89
|
+
def trial(cell)
|
|
90
|
+
Trial.new(experiment: @experiment, task_id: cell[:task], agent: cell[:agent],
|
|
91
|
+
condition: cell[:condition], container: @container)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def filtered_tasks(app:, task:)
|
|
95
|
+
tasks = @experiment.tasks
|
|
96
|
+
tasks = tasks.select { |t| t.app == app } if app
|
|
97
|
+
tasks = tasks.select { |t| t.id == task } if task
|
|
98
|
+
raise Error, "no tasks match #{{ app: app, task: task }.compact.inspect}" if tasks.empty?
|
|
99
|
+
|
|
100
|
+
tasks
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def filtered_agents(agent)
|
|
104
|
+
known = @experiment.agents.keys
|
|
105
|
+
return known unless agent
|
|
106
|
+
|
|
107
|
+
wanted = Array(agent).map(&:to_s)
|
|
108
|
+
unknown = wanted - known
|
|
109
|
+
if unknown.any?
|
|
110
|
+
raise Error,
|
|
111
|
+
"unknown agent(s): #{unknown.join(", ")} (experiment.yml declares #{known.join(", ")})"
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
wanted
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|