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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +9 -0
- data/LICENSE.txt +21 -0
- data/README.md +228 -0
- data/exe/lemans +17 -0
- data/lib/lemans/agents/base.rb +30 -0
- data/lib/lemans/agents/miniswen.rb +119 -0
- data/lib/lemans/agents/miniswen_installed.rb +67 -0
- data/lib/lemans/agents/nop.rb +15 -0
- data/lib/lemans/agents/oracle.rb +53 -0
- data/lib/lemans/agents.rb +21 -0
- data/lib/lemans/bench.rb +280 -0
- data/lib/lemans/cli/board_reporter.rb +135 -0
- data/lib/lemans/cli/progress_reporter.rb +67 -0
- data/lib/lemans/cli.rb +181 -0
- data/lib/lemans/clobber.rb +79 -0
- data/lib/lemans/environments/base.rb +55 -0
- data/lib/lemans/environments/daytona/retries.rb +49 -0
- data/lib/lemans/environments/daytona/sdk_tweaks.rb +50 -0
- data/lib/lemans/environments/daytona/shell.rb +142 -0
- data/lib/lemans/environments/daytona/snapshot_store.rb +163 -0
- data/lib/lemans/environments/daytona.rb +175 -0
- data/lib/lemans/environments.rb +16 -0
- data/lib/lemans/network_policy.rb +66 -0
- data/lib/lemans/patch.rb +70 -0
- data/lib/lemans/restore_paths.rb +21 -0
- data/lib/lemans/results/aggregate.rb +114 -0
- data/lib/lemans/results/cost_source.rb +13 -0
- data/lib/lemans/results/outcome.rb +36 -0
- data/lib/lemans/results/report.rb +149 -0
- data/lib/lemans/results/sorting.rb +24 -0
- data/lib/lemans/results/tally.rb +19 -0
- data/lib/lemans/results/usage.rb +24 -0
- data/lib/lemans/run.rb +152 -0
- data/lib/lemans/setup.rb +59 -0
- data/lib/lemans/setup_files.rb +36 -0
- data/lib/lemans/snapshot.rb +55 -0
- data/lib/lemans/task.rb +207 -0
- data/lib/lemans/tree_digest.rb +24 -0
- data/lib/lemans/trial.rb +187 -0
- data/lib/lemans/units.rb +44 -0
- data/lib/lemans/verifier/assets/eport-lemans.rb +36 -0
- data/lib/lemans/verifier/assets/lemans_minitest_reporter.rb +61 -0
- data/lib/lemans/verifier.rb +199 -0
- data/lib/lemans/version.rb +5 -0
- data/lib/lemans.rb +29 -0
- data/lib/miniswen/agent.rb +669 -0
- data/lib/miniswen/cli.rb +224 -0
- data/lib/miniswen/environment.rb +14 -0
- data/lib/miniswen/local.rb +42 -0
- data/lib/miniswen/ruby_llm.rb +42 -0
- data/lib/miniswen/testing.rb +134 -0
- data/lib/miniswen/trajectory.rb +110 -0
- data/lib/miniswen/version.rb +5 -0
- data/lib/miniswen.rb +48 -0
- metadata +160 -7
data/lib/lemans/bench.rb
ADDED
|
@@ -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
|