lemans 0.0.0.pre → 0.2.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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +9 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +228 -0
  5. data/exe/lemans +17 -0
  6. data/lib/lemans/agents/base.rb +30 -0
  7. data/lib/lemans/agents/miniswen.rb +119 -0
  8. data/lib/lemans/agents/miniswen_installed.rb +67 -0
  9. data/lib/lemans/agents/nop.rb +15 -0
  10. data/lib/lemans/agents/oracle.rb +53 -0
  11. data/lib/lemans/agents.rb +21 -0
  12. data/lib/lemans/bench.rb +280 -0
  13. data/lib/lemans/cli/board_reporter.rb +135 -0
  14. data/lib/lemans/cli/progress_reporter.rb +67 -0
  15. data/lib/lemans/cli.rb +181 -0
  16. data/lib/lemans/clobber.rb +79 -0
  17. data/lib/lemans/environments/base.rb +55 -0
  18. data/lib/lemans/environments/daytona/retries.rb +49 -0
  19. data/lib/lemans/environments/daytona/sdk_tweaks.rb +50 -0
  20. data/lib/lemans/environments/daytona/shell.rb +142 -0
  21. data/lib/lemans/environments/daytona/snapshot_store.rb +163 -0
  22. data/lib/lemans/environments/daytona.rb +175 -0
  23. data/lib/lemans/environments.rb +16 -0
  24. data/lib/lemans/network_policy.rb +66 -0
  25. data/lib/lemans/patch.rb +70 -0
  26. data/lib/lemans/restore_paths.rb +21 -0
  27. data/lib/lemans/results/aggregate.rb +114 -0
  28. data/lib/lemans/results/cost_source.rb +13 -0
  29. data/lib/lemans/results/outcome.rb +36 -0
  30. data/lib/lemans/results/report.rb +149 -0
  31. data/lib/lemans/results/sorting.rb +24 -0
  32. data/lib/lemans/results/tally.rb +19 -0
  33. data/lib/lemans/results/usage.rb +24 -0
  34. data/lib/lemans/run.rb +152 -0
  35. data/lib/lemans/setup.rb +59 -0
  36. data/lib/lemans/setup_files.rb +36 -0
  37. data/lib/lemans/snapshot.rb +55 -0
  38. data/lib/lemans/task.rb +207 -0
  39. data/lib/lemans/tree_digest.rb +24 -0
  40. data/lib/lemans/trial.rb +187 -0
  41. data/lib/lemans/units.rb +44 -0
  42. data/lib/lemans/verifier/assets/eport-lemans.rb +36 -0
  43. data/lib/lemans/verifier/assets/lemans_minitest_reporter.rb +61 -0
  44. data/lib/lemans/verifier.rb +199 -0
  45. data/lib/lemans/version.rb +5 -0
  46. data/lib/lemans.rb +29 -0
  47. data/lib/miniswen/agent.rb +669 -0
  48. data/lib/miniswen/cli.rb +224 -0
  49. data/lib/miniswen/environment.rb +14 -0
  50. data/lib/miniswen/local.rb +42 -0
  51. data/lib/miniswen/ruby_llm.rb +42 -0
  52. data/lib/miniswen/testing.rb +134 -0
  53. data/lib/miniswen/trajectory.rb +110 -0
  54. data/lib/miniswen/version.rb +5 -0
  55. data/lib/miniswen.rb +48 -0
  56. metadata +160 -7
@@ -0,0 +1,280 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+ require "open3"
6
+ require "pathname"
7
+ require "yaml"
8
+
9
+ module Lemans
10
+ # The frozen run profile, written once at the root of a bench: everything
11
+ # that must be identical across every trial. Task files carry only what differs.
12
+ class Bench
13
+ DEFAULT_FILENAME = "bench.yml"
14
+
15
+ # The machine shape a trial runs on; the same shape must mean the same thing on every backend.
16
+ Resources = Data.define(:cpus, :memory_mb, :storage_mb) do
17
+ # Each field falls back on its own, so naming one does not revert the others to defaults.
18
+ def self.from_config(config, field:, defaults:)
19
+ new(
20
+ cpus: config.fetch("cpus", defaults.cpus),
21
+ memory_mb: Units.megabytes(config["memory"], field: "#{field}.memory") || defaults.memory_mb,
22
+ storage_mb: Units.megabytes(config["storage"], field: "#{field}.storage") || defaults.storage_mb
23
+ )
24
+ end
25
+ end
26
+
27
+ DEFAULT_RESOURCES = Resources.new(cpus: 2, memory_mb: 2048, storage_mb: 5120)
28
+
29
+ # Which revision of a bench produced a score. Dirty is recorded rather
30
+ # than refused; a bench that is not a git checkout records nothing.
31
+ Revision = Data.define(:commit, :dirty) do
32
+ def self.none = new(commit: nil, dirty: nil)
33
+
34
+ def self.detect(dir)
35
+ commit = git("rev-parse", "HEAD", dir: dir)
36
+ return none if commit.nil?
37
+
38
+ status = git("status", "--porcelain", dir: dir)
39
+ new(commit: commit, dirty: status.nil? ? nil : !status.empty?)
40
+ end
41
+
42
+ def self.git(*args, dir:)
43
+ output, status = Open3.capture2e("git", "-C", dir.to_s, *args)
44
+ status.success? ? output.strip : nil
45
+ rescue SystemCallError
46
+ # No git on this machine. Recording nothing beats taking the run down.
47
+ nil
48
+ end
49
+
50
+ private_class_method :git
51
+
52
+ def to_h = { commit: commit, dirty: dirty }
53
+ end
54
+
55
+ # One section of bench.yml. This base class carries validation logic
56
+ class Section
57
+ def initialize(config, name)
58
+ @config = config || {}
59
+ @name = name
60
+ end
61
+
62
+ def validate!
63
+ self.class::VALIDATED.each { public_send(_1) }
64
+ freeze
65
+ end
66
+
67
+ private
68
+
69
+ attr_reader :config
70
+
71
+ def [](key) = @config[key]
72
+
73
+ def fetch(key, *default)
74
+ @config.fetch(key, *default)
75
+ rescue KeyError
76
+ raise ConfigError, "#{dotted(key)} is required"
77
+ end
78
+
79
+ def seconds(key, default: nil) = Units.seconds(self[key] || default, field: dotted(key))
80
+
81
+ # Strict on purpose: `to_i` would read a typo as 0, which downstream
82
+ # means "no limit" for steps and "stop before the first call" for cost.
83
+ def integer(key)
84
+ value = self[key]
85
+ value.nil? ? nil : Integer(value)
86
+ rescue ArgumentError, TypeError
87
+ raise ConfigError, "#{dotted(key)}: cannot read #{value.inspect} as a number"
88
+ end
89
+
90
+ def float(key)
91
+ value = self[key]
92
+ value.nil? ? nil : Float(value)
93
+ rescue ArgumentError, TypeError
94
+ raise ConfigError, "#{dotted(key)}: cannot read #{value.inspect} as a number"
95
+ end
96
+
97
+ def policy(key = "network") = NetworkPolicy.from_config(self[key], field: dotted(key))
98
+
99
+ # A step that is not a string is caught here rather than reaching a sandbox as the word "true".
100
+ def commands(key)
101
+ Array(self[key]).each_with_index.map do |step, index|
102
+ raise ConfigError, "#{dotted(key)}[#{index}] must be a command string, got #{step.inspect}" unless step.is_a?(String)
103
+
104
+ step
105
+ end.freeze
106
+ end
107
+
108
+ def dotted(key) = "#{@name}.#{key}"
109
+ end
110
+
111
+ # The `environment` block: the one machine a trial runs on. The agent
112
+ # works in it, and the verifier verifies in it.
113
+ class Environment < Section
114
+ VALIDATED = %i[image workdir resources build_timeout_sec network setup].freeze
115
+
116
+ def initialize(config) = super(config, "environment")
117
+
118
+ # A bench that names no image builds one per task from each task's own Dockerfile.
119
+ def image = self["image"]
120
+
121
+ def workdir
122
+ fetch("workdir", "/app").tap do |dir|
123
+ raise ConfigError, "#{dotted("workdir")} must be an absolute path, got #{dir.inspect}" unless
124
+ dir.start_with?("/")
125
+ end
126
+ end
127
+
128
+ def resources
129
+ Resources.from_config(self["resources"] || {}, field: dotted("resources"), defaults: DEFAULT_RESOURCES)
130
+ end
131
+
132
+ def build_timeout_sec = seconds("build_timeout", default: "10m")
133
+
134
+ def network = policy
135
+
136
+ def setup = commands("setup")
137
+ end
138
+
139
+ # The `agent` section: who works the task and under what budget.
140
+ class Agent < Section
141
+ VALIDATED = %i[name version model timeout_sec step_limit cost_limit
142
+ exec_timeout_sec config models network].freeze
143
+
144
+ def initialize(config) = super(config, "agent")
145
+
146
+ def name = fetch("name")
147
+ def version = self["version"]&.to_s
148
+ def model = models.first
149
+ def timeout_sec = seconds("timeout", default: "30m")
150
+ def step_limit = integer("step_limit") || 0
151
+ def cost_limit = float("cost_limit")
152
+ def exec_timeout_sec = seconds("exec_timeout", default: 30)
153
+ def config = (self["config"] || {}).freeze
154
+
155
+ # `model` takes one name or a list; a list turns the run into a sweep, one full grid per model.
156
+ def models = Array(self["model"]).map(&:to_s).freeze
157
+
158
+ # The only environment knob the agent phase owns: `agent.environment.network`.
159
+ def network
160
+ NetworkPolicy.from_config((self["environment"] || {})["network"], field: dotted("environment.network"))
161
+ end
162
+ end
163
+
164
+ # The `verifier` section: how a finished trial is verified, in the same
165
+ # sandbox the agent worked in, after Trial closes its network.
166
+ class Verifier < Section
167
+ DEFAULT_COMMAND = "if [ -x /tests/verify ]; then exec /tests/verify; " \
168
+ "elif [ -f /tests/verification_test.rb ]; then exec ruby -report-lemans /tests/verification_test.rb; " \
169
+ "else exec bash /tests/test.sh; fi"
170
+
171
+ VALIDATED = %i[timeout_sec setup preverify command restore_paths logs_dir reward_path].freeze
172
+
173
+ def initialize(config) = super(config, "verifier")
174
+
175
+ def timeout_sec = seconds("timeout", default: "10m")
176
+
177
+ # These run after the network closes, so anything they need must already be in the image.
178
+ def setup = commands("setup")
179
+
180
+ def command = fetch("command", DEFAULT_COMMAND)
181
+
182
+ def preverify
183
+ self["preverify"].tap do |command|
184
+ raise ConfigError, "#{dotted("preverify")} must be a command string, got #{command.inspect}" unless
185
+ command.nil? || command.is_a?(String)
186
+ end
187
+ end
188
+
189
+ # The graded surfaces restored from the pre-agent snapshot before the
190
+ # command runs; a task may override the list in its frontmatter.
191
+ def restore_paths = RestorePaths.call(self["restore"], label: dotted("restore"))
192
+
193
+ def logs_dir
194
+ fetch("logs_dir", "/logs/verifier").tap do |dir|
195
+ raise ConfigError, "verifier.logs_dir must be an absolute path, got #{dir.inspect}" unless
196
+ dir.start_with?("/")
197
+ end
198
+ end
199
+
200
+ # Derived, never declared: the reward lands beside the logs that justify it.
201
+ def reward_path = "#{logs_dir.chomp("/")}/reward.txt"
202
+ end
203
+
204
+ attr_reader :path, :root, :environment, :agent, :verifier, :revision
205
+
206
+ def self.load(path)
207
+ path = Pathname(path)
208
+ path = path.join(DEFAULT_FILENAME) if path.directory?
209
+ raise ConfigError, "no #{DEFAULT_FILENAME} at #{path}" unless path.file?
210
+
211
+ config = YAML.safe_load_file(path, aliases: true) || {}
212
+ raise ConfigError, "#{path}: bench.yml must be a mapping of sections" unless config.is_a?(Hash)
213
+
214
+ new(config, path: path)
215
+ rescue Psych::Exception => e
216
+ raise ConfigError, "#{path}: #{e.message}"
217
+ end
218
+
219
+ def initialize(config, path:)
220
+ @path = Pathname(path)
221
+ @root = @path.dirname
222
+ @config = config
223
+
224
+ @environment = Environment.new(section("environment"))
225
+ @environment.validate!
226
+ @agent = Agent.new(section("agent"))
227
+ @agent.validate!
228
+ @verifier = Verifier.new(section("verifier"))
229
+ @verifier.validate!
230
+
231
+ @files = SetupFiles.call(@config["files"], root: @root, label: @path)
232
+ # Resolved once: an hours-long run reports the bench it started from, not later tree drift.
233
+ @revision = Revision.detect(@root)
234
+ digest
235
+ freeze
236
+ end
237
+
238
+ # Recorded on every result: two trials are only comparable under the same
239
+ # profile, and the bench's own files count as profile.
240
+ def digest
241
+ @digest ||= Digest::SHA256.hexdigest(JSON.generate([@config, file_digests]))[0, 16]
242
+ end
243
+
244
+ def setup_files(phase) = @files.fetch(phase.to_sym, [])
245
+
246
+ # The only thing a result carries that pins the bytes of the scripts a trial ran.
247
+ # Shared verification files count: they grade every trial.
248
+ def file_digests
249
+ @file_digests ||= begin
250
+ shared = verification_files.map { |absolute, _| absolute.relative_path_from(root) }
251
+ (@files.values.flatten + shared).map(&:to_s).sort.uniq.to_h do |path|
252
+ [path, Digest::SHA256.file(root.join(path)).hexdigest]
253
+ end
254
+ end.freeze
255
+ end
256
+
257
+ VERIFICATION_DIR = "verification"
258
+
259
+ def verification_files
260
+ dir = root.join(VERIFICATION_DIR)
261
+ return [] unless dir.directory?
262
+
263
+ dir.glob("**/*", File::FNM_DOTMATCH).select(&:file?).map { [_1, _1.relative_path_from(dir).to_s] }
264
+ end
265
+
266
+ def tasks_dir = root.join(@config.fetch("tasks", "tasks"))
267
+
268
+ def tasks
269
+ raise ConfigError, "no tasks directory at #{tasks_dir}" unless tasks_dir.directory?
270
+
271
+ tasks_dir.children.select(&:directory?).sort.map { Task.load(_1, bench: self) }
272
+ end
273
+
274
+ private
275
+
276
+ def section(key)
277
+ @config[key] or raise ConfigError, "#{path}: #{key} section is required"
278
+ end
279
+ end
280
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lemans
4
+ class CLI < Thor
5
+ # A live table — tasks down, models across — redrawn in place, each cell
6
+ # one glyph per attempt: · queued, spinner running, ✔ solved, ✘ scored short, ! invalid.
7
+ class BoardReporter
8
+ FRAMES = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze
9
+ REDRAW_SEC = 0.1
10
+ MAX_DETAIL_CHARS = 200
11
+
12
+ GREEN = "\e[32m"
13
+ RED = "\e[31m"
14
+ YELLOW = "\e[33m"
15
+ DIM = "\e[2m"
16
+ RESET = "\e[0m"
17
+
18
+ def initialize(tasks:, models:, attempts:, total: nil, out: $stderr)
19
+ @tasks = tasks
20
+ @models = models.map { short(_1) }
21
+ @attempts = attempts
22
+ # Injected when known: under --resume the schedule is smaller than
23
+ # tasks × models × attempts.
24
+ @total = total || (tasks.size * models.size * attempts)
25
+ @out = out
26
+ @lock = Mutex.new
27
+ @cells = Hash.new { |cells, key| cells[key] = Array.new(@attempts, :queued) }
28
+ @drawn = 0
29
+ @frame = 0
30
+ @done = 0
31
+ @in_flight = 0
32
+ end
33
+
34
+ def start
35
+ @thread = Thread.new do
36
+ loop do
37
+ @lock.synchronize { draw }
38
+ sleep REDRAW_SEC
39
+ end
40
+ end
41
+ self
42
+ end
43
+
44
+ def record(event, data)
45
+ @lock.synchronize do
46
+ case event
47
+ when :started
48
+ @in_flight += 1
49
+ cell(data)[data[:index] - 1] = :running
50
+ when :finished
51
+ @in_flight -= 1
52
+ @done += 1
53
+ cell(data)[data[:index] - 1] = data
54
+ announce_error(data)
55
+ when :interrupted
56
+ erase
57
+ @out.puts "#{YELLOW}^C — waiting for #{data[:in_flight]} in-flight trial(s), ^C again to abandon#{RESET}"
58
+ @drawn = 0
59
+ end
60
+ end
61
+ end
62
+
63
+ def stop
64
+ return unless @thread
65
+
66
+ @thread.kill
67
+ @thread = nil
68
+ @lock.synchronize { draw } # the final frame stays on screen
69
+ @out.puts
70
+ end
71
+
72
+ private
73
+
74
+ def announce_error(data)
75
+ return if data[:scored] || data[:detail].nil?
76
+
77
+ erase
78
+ @out.puts "\e[2K#{RED}#{data[:task]}: #{data[:outcome]} — " \
79
+ "#{data[:detail].to_s.lines.first.to_s.strip[0, MAX_DETAIL_CHARS]}#{RESET}"
80
+ @drawn = 0
81
+ end
82
+
83
+ def cell(data) = @cells[[data[:task], short(data[:model])]]
84
+
85
+ # A bench may declare no model at all; nil must not reach ljust.
86
+ def short(model) = model.nil? ? "(default)" : model.to_s.split("/").last
87
+
88
+ def draw
89
+ @frame += 1
90
+ task_width = (@tasks.map(&:length) + [4]).max
91
+ cell_width = ([@attempts, 3].max + 2)
92
+ lines = [header(task_width, cell_width)]
93
+ @tasks.each { lines << row(_1, task_width, cell_width) }
94
+ lines << "#{DIM}#{FRAMES[@frame % FRAMES.size]} #{@done}/#{@total} done " \
95
+ "· #{@in_flight} in flight#{RESET}"
96
+
97
+ erase
98
+ @out.print lines.map { "\e[2K#{_1}" }.join("\n")
99
+ @drawn = lines.size
100
+ end
101
+
102
+ def erase
103
+ @out.print("\r")
104
+ @out.print("\e[#{@drawn - 1}F") if @drawn > 1
105
+ end
106
+
107
+ def header(task_width, cell_width)
108
+ "#{DIM}#{"task".ljust(task_width)} #{@models.map { _1.ljust([_1.length, cell_width].max) }.join(" ")}#{RESET}"
109
+ end
110
+
111
+ # ljust would count the glyphs' invisible ANSI bytes, so cells pad by
112
+ # visible width — one column per attempt — instead.
113
+ def row(task, task_width, cell_width)
114
+ cells = @models.map do |model|
115
+ states = @cells[[task, model]]
116
+ pad = [model.length, cell_width].max - states.size
117
+ states.map { glyph(_1) }.join + (" " * [pad, 0].max)
118
+ end
119
+ "#{task.ljust(task_width)} #{cells.join(" ")}"
120
+ end
121
+
122
+ def glyph(state)
123
+ case state
124
+ when :queued then "#{DIM}·#{RESET}"
125
+ when :running then FRAMES[@frame % FRAMES.size]
126
+ else
127
+ if !state[:scored] then "#{RED}!#{RESET}"
128
+ elsif state[:reward].to_f >= 1.0 then "#{GREEN}✔#{RESET}"
129
+ else "#{YELLOW}✘#{RESET}"
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lemans
4
+ class CLI < Thor
5
+ # The pipe renderer: one plain line per event
6
+ class ProgressReporter
7
+ # say_status's verb column is 12 wide; the longer outcome names get a
8
+ # short verb here and keep their full name in the table and result.json.
9
+ STATUS_VERBS = {
10
+ completed: :completed,
11
+ agent_timeout: :timeout,
12
+ step_limit_reached: :step_limit,
13
+ cost_ceiling_reached: :cost_limit,
14
+ environment_error: :invalid,
15
+ agent_error: :invalid,
16
+ accounting_error: :invalid,
17
+ verifier_error: :invalid,
18
+ harness_crash: :invalid
19
+ }.freeze
20
+
21
+ MAX_DETAIL_CHARS = 200
22
+
23
+ def initialize(shell:, task_width:)
24
+ @shell = shell
25
+ @task_width = task_width
26
+ end
27
+
28
+ def start = self
29
+
30
+ def record(event, data)
31
+ case event
32
+ when :started then started(data)
33
+ when :finished then finished(data)
34
+ when :interrupted
35
+ @shell.say_status :interrupt,
36
+ "waiting for #{data[:in_flight]} in-flight trial(s), ^C again to abandon", :yellow
37
+ end
38
+ end
39
+
40
+ def stop; end
41
+
42
+ private
43
+
44
+ def started(data)
45
+ attempt = "attempt #{data[:index].to_s.rjust(data[:attempts].to_s.length)}/#{data[:attempts]}"
46
+ @shell.say_status :run, "#{data[:task].to_s.ljust(@task_width)} #{attempt} #{data[:trial]}", :blue
47
+ end
48
+
49
+ def finished(data)
50
+ status = data[:scored] ? "reward=#{data[:reward].inspect}" : data[:outcome].to_s
51
+ @shell.say_status STATUS_VERBS.fetch(data[:outcome].to_sym, data[:outcome].to_sym),
52
+ "#{data[:task].to_s.ljust(@task_width)} #{status.ljust(12)} #{data[:duration_sec]}s",
53
+ color(data)
54
+
55
+ @shell.say_status :error, first_line(data[:detail]), :red unless data[:scored] || data[:detail].nil?
56
+ end
57
+
58
+ def first_line(detail) = detail.to_s.lines.first.to_s.strip[0, MAX_DETAIL_CHARS]
59
+
60
+ def color(data)
61
+ return :red unless data[:scored]
62
+
63
+ data[:reward].to_f >= 1.0 ? :green : :yellow
64
+ end
65
+ end
66
+ end
67
+ end
data/lib/lemans/cli.rb ADDED
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "lemans"
4
+ require "thor"
5
+
6
+ module Lemans
7
+ # The commands. Thin on purpose: everything a command does is a call into a
8
+ # class somebody can drive without a terminal.
9
+ class CLI < Thor
10
+ check_unknown_options!
11
+
12
+ def self.exit_on_failure? = true
13
+
14
+ map %w[-v --version] => :version
15
+ desc "version", "Print the lemans version"
16
+ def version
17
+ say VERSION
18
+ end
19
+
20
+ desc "tasks", "List the tasks in a bench"
21
+ option :bench, default: ".", desc: "Directory holding bench.yml"
22
+ option :tag, desc: "Only tasks carrying this tag"
23
+ def tasks
24
+ bench = Bench.load(options[:bench])
25
+ print_table(
26
+ [%w[task difficulty tags description]] +
27
+ select_tasks(bench).map { [_1.name, _1.difficulty, _1.tags.join(","), _1.description] }
28
+ )
29
+ rescue ConfigError => e
30
+ raise Thor::Error, "lemans: #{e.message}"
31
+ end
32
+
33
+ map "run" => :run_bench
34
+ desc "run", "Run tasks and verify them"
35
+ option :bench, default: ".", desc: "Directory holding bench.yml"
36
+ option :task, desc: "Run task(s) by name", repeatable: true
37
+ option :tag, desc: "Run every task carrying this tag"
38
+ option :agent, desc: "Override the agent from bench.yml (miniswen, oracle, nop)"
39
+ option :model, desc: "Override the model(s) from bench.yml", repeatable: true
40
+ option :attempts, type: :numeric, default: 1, aliases: "-k", desc: "Trials per task"
41
+ option :concurrency, type: :numeric, default: 4, aliases: "-c", desc: "Trials in flight at once"
42
+ option :runs_dir, default: "runs", desc: "Where to write run directories"
43
+ option :backend, default: "daytona", enum: Environments::BACKENDS.keys, desc: "Sandbox backend"
44
+ option :resume, type: :boolean, default: false, desc: "Skip trials that already have a result"
45
+ def run_bench
46
+ # The bundled pricing registry ages faster than the gem: refresh once up
47
+ # front, so every trial prices completions against the same revision.
48
+ Miniswen.refresh_registry!
49
+
50
+ bench = Bench.load(options[:bench])
51
+ tasks = select_tasks(bench)
52
+
53
+ run = Run.new(
54
+ bench: bench,
55
+ tasks: tasks,
56
+ agent_name: options[:agent] || bench.agent.name,
57
+ model: options[:model],
58
+ backend: options[:backend],
59
+ runs_dir: options[:runs_dir],
60
+ attempts: Integer(options[:attempts]),
61
+ concurrency: Integer(options[:concurrency]),
62
+ resume: options[:resume]
63
+ )
64
+ if run.total.zero?
65
+ say_status :resume, "nothing to run — every task × model already has " \
66
+ "#{options[:attempts]} scored attempt(s)", :green
67
+ end
68
+
69
+ # A tty gets the live board; a pipe gets plain streamed lines.
70
+ progress =
71
+ if interactive?
72
+ models = options[:model] || (bench.agent.models.empty? ? [bench.agent.model] : bench.agent.models)
73
+ BoardReporter.new(tasks: tasks.map(&:name), models: models,
74
+ attempts: Integer(options[:attempts]), total: run.total)
75
+ else
76
+ ProgressReporter.new(shell: shell, task_width: tasks.map { _1.name.length }.max)
77
+ end
78
+ progress.start
79
+ summary = run.call { |event, data| progress.record(event, data) }
80
+ progress.stop
81
+
82
+ say ""
83
+ say_status :report, "collecting results from #{options[:runs_dir]}", :cyan
84
+ print_report Results::Report.load(options[:runs_dir])
85
+ exit 130 if summary[:interrupted]
86
+ exit 1 if summary[:invalid].positive?
87
+ rescue ConfigError => e
88
+ raise Thor::Error, "lemans: #{e.message}"
89
+ rescue Interrupt
90
+ say ""
91
+ exit 130
92
+ ensure
93
+ progress&.stop
94
+ end
95
+
96
+ desc "clobber", "Delete run results"
97
+ option :runs_dir, default: "runs", desc: "Directory holding run directories"
98
+ option :task, type: :array, desc: "Only these tasks' runs (space-separated)"
99
+ option :ttl, desc: "Only runs older than this (10m, 2h, 1d)"
100
+ option :invalid, type: :boolean, default: false, desc: "Only runs that measured nothing (invalid or unreadable)"
101
+ option :force, type: :boolean, default: false, aliases: "-f", desc: "Delete without asking"
102
+ def clobber
103
+ clobber = Clobber.new(
104
+ runs_dir: options[:runs_dir],
105
+ tasks: options[:task],
106
+ ttl_sec: Units.seconds(options[:ttl], field: "--ttl"),
107
+ invalid: options[:invalid]
108
+ )
109
+ doomed = clobber.matches
110
+ return say "lemans: nothing to clobber under #{options[:runs_dir]}" if doomed.empty?
111
+
112
+ unless options[:force]
113
+ doomed.each { say _1.to_s }
114
+ return say "lemans: nothing deleted" unless yes?("Delete #{doomed.size} run(s) under #{options[:runs_dir]}? [y/N]")
115
+ end
116
+
117
+ removed = clobber.call
118
+ say "deleted #{removed.size} run(s)"
119
+ rescue ConfigError => e
120
+ raise Thor::Error, "lemans: #{e.message}"
121
+ end
122
+
123
+ desc "report", "Summarize run results as a table or CSV"
124
+ option :runs_dir, default: "runs", desc: "Directory holding run directories"
125
+ option :tag, desc: "Only runs whose result carries this tag"
126
+ option :format, default: "table", enum: %w[table csv], desc: "Output format"
127
+ option :aggregate, aliases: "-A", banner: "COLUMNS", lazy_default: "task-model",
128
+ desc: "Group results by 1-3 dash-joined columns (task, agent, model)"
129
+ option :sort, aliases: "-S", banner: "COLUMN", desc: "Sort by a column"
130
+ def report
131
+ results = Results::Report.load(options[:runs_dir], tag: options[:tag])
132
+ if results.empty?
133
+ tagged = options[:tag] ? " tagged #{options[:tag].inspect}" : ""
134
+ raise Thor::Error, "lemans: no results#{tagged} under #{options[:runs_dir]}"
135
+ end
136
+
137
+ results = Results::Aggregate.new(results, keys: Results::Aggregate.keys(options[:aggregate])) if options[:aggregate]
138
+ results.order_by!(options[:sort]) if options[:sort]
139
+ options[:format] == "csv" ? say(results.to_csv) : print_report(results)
140
+ rescue ConfigError => e
141
+ raise Thor::Error, "lemans: #{e.message}"
142
+ end
143
+
144
+ private
145
+
146
+ # The one task filter for every command that walks a bench: an empty
147
+ # selection is an error, because running or listing nothing is never
148
+ # what a named task or tag meant.
149
+ def select_tasks(bench)
150
+ tasks = bench.tasks
151
+ tasks = tasks.select { options[:task].include?(_1.name) } if options[:task]
152
+ tasks = tasks.select { _1.tags.include?(options[:tag]) } if options[:tag]
153
+ return tasks unless tasks.empty?
154
+
155
+ wanted = [options[:task] && "task named #{options[:task].inspect}",
156
+ options[:tag] && "task tagged #{options[:tag].inspect}"].compact.join(" and no ")
157
+ raise Thor::Error, "lemans: no #{wanted.empty? ? "tasks in #{options[:bench]}" : wanted}"
158
+ end
159
+
160
+ def print_report(report)
161
+ print_table report.to_rows
162
+ color = report.summary[:invalid].positive? ? :red : nil
163
+ report.summary_lines.each { say _1, color }
164
+ end
165
+
166
+ def interactive?
167
+ return false unless $stderr.tty?
168
+
169
+ return true if ENV["FORCE_INTERACTIVE"] == "1"
170
+
171
+ # Check various env vars indicating non-interactive mode
172
+ if ENV["NONINTERACTIVE"] == "1" ||
173
+ ENV["CI"] == "true" ||
174
+ ENV["TERM"] == "dumb"
175
+ return false
176
+ end
177
+
178
+ true
179
+ end
180
+ end
181
+ end