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.
Files changed (48) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +40 -0
  3. data/CODE_OF_CONDUCT.md +74 -0
  4. data/LICENSE.txt +21 -0
  5. data/README.md +190 -0
  6. data/exe/llmx +6 -0
  7. data/lib/llm-experiment.rb +3 -0
  8. data/lib/llm_experiment/auth.rb +143 -0
  9. data/lib/llm_experiment/cleaner.rb +183 -0
  10. data/lib/llm_experiment/cli/build_command.rb +37 -0
  11. data/lib/llm_experiment/cli/clean_command.rb +42 -0
  12. data/lib/llm_experiment/cli/doctor_command.rb +30 -0
  13. data/lib/llm_experiment/cli/login_command.rb +25 -0
  14. data/lib/llm_experiment/cli/metrics_command.rb +32 -0
  15. data/lib/llm_experiment/cli/new_command.rb +21 -0
  16. data/lib/llm_experiment/cli/parse_command.rb +77 -0
  17. data/lib/llm_experiment/cli/run_command.rb +74 -0
  18. data/lib/llm_experiment/cli/sanitize_command.rb +34 -0
  19. data/lib/llm_experiment/cli/shell_command.rb +35 -0
  20. data/lib/llm_experiment/cli/status_command.rb +69 -0
  21. data/lib/llm_experiment/cli/version_command.rb +19 -0
  22. data/lib/llm_experiment/cli.rb +120 -0
  23. data/lib/llm_experiment/container.rb +178 -0
  24. data/lib/llm_experiment/doctor.rb +126 -0
  25. data/lib/llm_experiment/experiment.rb +163 -0
  26. data/lib/llm_experiment/grid.rb +117 -0
  27. data/lib/llm_experiment/image_builder/app.rb +267 -0
  28. data/lib/llm_experiment/image_builder/base.rb +64 -0
  29. data/lib/llm_experiment/metrics_report.rb +138 -0
  30. data/lib/llm_experiment/pins.rb +22 -0
  31. data/lib/llm_experiment/sanitizer.rb +144 -0
  32. data/lib/llm_experiment/scaffold.rb +43 -0
  33. data/lib/llm_experiment/shell.rb +60 -0
  34. data/lib/llm_experiment/stats.rb +69 -0
  35. data/lib/llm_experiment/transcript/claude.rb +104 -0
  36. data/lib/llm_experiment/transcript/codex.rb +96 -0
  37. data/lib/llm_experiment/transcript/hermeticity.rb +35 -0
  38. data/lib/llm_experiment/transcript/parser.rb +105 -0
  39. data/lib/llm_experiment/transcript.rb +27 -0
  40. data/lib/llm_experiment/trial.rb +204 -0
  41. data/lib/llm_experiment/version.rb +5 -0
  42. data/lib/llm_experiment.rb +70 -0
  43. data/templates/README.md.erb +26 -0
  44. data/templates/base.Containerfile +120 -0
  45. data/templates/experiment.yml.erb +30 -0
  46. data/templates/gitignore +3 -0
  47. data/templates/runner.rb +305 -0
  48. 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