lemans 0.2.2 → 1.0.0.pre.1
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/README.md +30 -10
- data/exe/lemans-remote +161 -46
- data/lib/lemans/agent.rb +39 -0
- data/lib/lemans/agents/miniswen.rb +28 -25
- data/lib/lemans/agents/miniswen_installed.rb +13 -9
- data/lib/lemans/agents/nop.rb +3 -3
- data/lib/lemans/agents/oracle.rb +8 -8
- data/lib/lemans/cli/board_reporter.rb +16 -16
- data/lib/lemans/cli/progress_reporter.rb +19 -19
- data/lib/lemans/cli/report/aggregate.rb +117 -0
- data/lib/lemans/cli/report.rb +164 -0
- data/lib/lemans/cli.rb +58 -63
- data/lib/lemans/clobber.rb +17 -55
- data/lib/lemans/config/agent.rb +51 -0
- data/lib/lemans/config/conversion.rb +62 -0
- data/lib/lemans/config/environment.rb +39 -0
- data/lib/lemans/config/image_spec.rb +42 -0
- data/lib/lemans/config/network_policy.rb +55 -0
- data/lib/lemans/config/revision.rb +42 -0
- data/lib/lemans/config/setup.rb +57 -0
- data/lib/lemans/config/tree_digest.rb +26 -0
- data/lib/lemans/config/verifier.rb +72 -0
- data/lib/lemans/config.rb +107 -0
- data/lib/lemans/environment.rb +54 -0
- data/lib/lemans/environments/daytona/faraday_transfer.rb +172 -0
- data/lib/lemans/environments/daytona/sdk_tweaks.rb +1 -1
- data/lib/lemans/environments/daytona/shell.rb +1 -1
- data/lib/lemans/environments/daytona/snapshot_store.rb +11 -11
- data/lib/lemans/environments/daytona.rb +21 -35
- data/lib/lemans/result.rb +270 -0
- data/lib/lemans/runner/executor.rb +64 -0
- data/lib/lemans/runner/task.rb +62 -0
- data/lib/lemans/runner.rb +82 -0
- data/lib/lemans/store.rb +44 -0
- data/lib/lemans/stores/fs.rb +122 -0
- data/lib/lemans/task_definition.rb +194 -0
- data/lib/lemans/trial/patch.rb +76 -0
- data/lib/lemans/trial/setup.rb +66 -0
- data/lib/lemans/trial/snapshot.rb +57 -0
- data/lib/lemans/trial/verifier.rb +190 -0
- data/lib/lemans/trial.rb +113 -147
- data/lib/lemans/version.rb +1 -1
- data/lib/lemans.rb +3 -2
- data/lib/miniswen/trajectory.rb +2 -0
- metadata +58 -25
- data/lib/lemans/agents/base.rb +0 -30
- data/lib/lemans/bench.rb +0 -280
- data/lib/lemans/environments/base.rb +0 -55
- data/lib/lemans/network_policy.rb +0 -66
- data/lib/lemans/patch.rb +0 -70
- data/lib/lemans/restore_paths.rb +0 -21
- data/lib/lemans/results/aggregate.rb +0 -114
- data/lib/lemans/results/cost_source.rb +0 -13
- data/lib/lemans/results/outcome.rb +0 -36
- data/lib/lemans/results/report.rb +0 -149
- data/lib/lemans/results/sorting.rb +0 -24
- data/lib/lemans/results/tally.rb +0 -19
- data/lib/lemans/results/usage.rb +0 -24
- data/lib/lemans/run.rb +0 -152
- data/lib/lemans/setup.rb +0 -59
- data/lib/lemans/setup_files.rb +0 -36
- data/lib/lemans/snapshot.rb +0 -55
- data/lib/lemans/task.rb +0 -207
- data/lib/lemans/tree_digest.rb +0 -24
- data/lib/lemans/units.rb +0 -44
- data/lib/lemans/verifier.rb +0 -199
- /data/lib/lemans/{verifier → trial/verifier}/assets/eport-lemans.rb +0 -0
- /data/lib/lemans/{verifier → trial/verifier}/assets/lemans_minitest_reporter.rb +0 -0
data/lib/lemans/bench.rb
DELETED
|
@@ -1,280 +0,0 @@
|
|
|
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
|
|
@@ -1,55 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Lemans
|
|
4
|
-
module Environments
|
|
5
|
-
# What every backend must do, and nothing more: a narrow contract is what
|
|
6
|
-
# makes a second backend a day of work instead of a subsystem.
|
|
7
|
-
class Base
|
|
8
|
-
# Backends interleave a command's streams before we ever see them, so one
|
|
9
|
-
# output field is the honest shape.
|
|
10
|
-
ExecResult = Data.define(:command, :exit_code, :output, :duration_sec) do
|
|
11
|
-
def success? = exit_code.zero?
|
|
12
|
-
end
|
|
13
|
-
|
|
14
|
-
attr_reader :image, :resources, :network, :env, :labels, :build_timeout_sec
|
|
15
|
-
|
|
16
|
-
# `labels` is backend-agnostic trial metadata (task, trial id, phase);
|
|
17
|
-
# every backend receives it even if it has nowhere to put it.
|
|
18
|
-
def initialize(image:, resources:, network:, env: {}, labels: {}, build_timeout_sec: nil)
|
|
19
|
-
@image = image
|
|
20
|
-
@resources = resources
|
|
21
|
-
@network = network
|
|
22
|
-
@env = env
|
|
23
|
-
@labels = labels
|
|
24
|
-
@build_timeout_sec = build_timeout_sec
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
# Build the image and bring the sandbox up under the network policy it was
|
|
28
|
-
# constructed with; a backend that cannot honour the policy must raise.
|
|
29
|
-
def start = raise(NotImplementedError)
|
|
30
|
-
|
|
31
|
-
DEFAULT_TIMEOUT = 60
|
|
32
|
-
|
|
33
|
-
def exec(command, timeout: nil, env: {}) = raise(NotImplementedError)
|
|
34
|
-
|
|
35
|
-
def upload(local_path, remote_path) = raise(NotImplementedError)
|
|
36
|
-
|
|
37
|
-
def download(remote_path, local_path) = raise(NotImplementedError)
|
|
38
|
-
|
|
39
|
-
# Phases change what the sandbox may reach: setup pulls packages, the agent
|
|
40
|
-
# reaches the model API and nothing else, the verifier reaches nothing.
|
|
41
|
-
def network_policy=(policy)
|
|
42
|
-
raise NotImplementedError
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
def stop = raise(NotImplementedError)
|
|
46
|
-
|
|
47
|
-
def exec!(command, **)
|
|
48
|
-
result = exec(command, **)
|
|
49
|
-
return result if result.success?
|
|
50
|
-
|
|
51
|
-
raise InfrastructureError, "#{command} exited #{result.exit_code}: #{result.output.to_s[0, 2000]}"
|
|
52
|
-
end
|
|
53
|
-
end
|
|
54
|
-
end
|
|
55
|
-
end
|
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "ipaddr"
|
|
4
|
-
|
|
5
|
-
module Lemans
|
|
6
|
-
# What a phase is allowed to reach. Every phase names its policy explicitly:
|
|
7
|
-
# network: { mode: allowlist, hosts: [openrouter.ai, "*.example.com", 10.0.0.0/8] }
|
|
8
|
-
class NetworkPolicy
|
|
9
|
-
MODES = %i[none allowlist public].freeze
|
|
10
|
-
|
|
11
|
-
attr_reader :mode, :hosts, :domains, :ip_targets
|
|
12
|
-
|
|
13
|
-
def self.from_config(config, field:)
|
|
14
|
-
raise ConfigError, "#{field}: network policy is required" if config.nil?
|
|
15
|
-
|
|
16
|
-
mode = config["mode"] or raise ConfigError, "#{field}.mode is required (#{MODES.join(", ")})"
|
|
17
|
-
new(mode: mode.to_s.to_sym, hosts: config["hosts"] || [], field: field)
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
def self.none = new(mode: :none)
|
|
21
|
-
|
|
22
|
-
def initialize(mode:, hosts: [], field: "network")
|
|
23
|
-
unless MODES.include?(mode)
|
|
24
|
-
raise ConfigError,
|
|
25
|
-
"#{field}.mode: #{mode.inspect} is not one of #{MODES.join(", ")}"
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
raise ConfigError, "#{field}.hosts must be a list" unless hosts.is_a?(Array)
|
|
29
|
-
if mode != :allowlist && !hosts.empty?
|
|
30
|
-
raise ConfigError,
|
|
31
|
-
"#{field}.hosts is only meaningful with mode: allowlist"
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
raise ConfigError, "#{field}.hosts cannot be empty with mode: allowlist" if mode == :allowlist && hosts.empty?
|
|
35
|
-
|
|
36
|
-
hosts = validated_hosts(hosts, field)
|
|
37
|
-
|
|
38
|
-
@mode = mode
|
|
39
|
-
@hosts = hosts.freeze
|
|
40
|
-
# Split once, at construction: backends allowlist domains and IP ranges
|
|
41
|
-
# through separate APIs, and a bad entry must fail here, loudly — a
|
|
42
|
-
# malformed allowlist must never launch a sandbox open.
|
|
43
|
-
@ip_targets, @domains = hosts.partition { ip_target?(_1) }.map(&:freeze)
|
|
44
|
-
freeze
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
def to_h = { mode: mode, hosts: hosts }
|
|
48
|
-
|
|
49
|
-
private
|
|
50
|
-
|
|
51
|
-
def validated_hosts(hosts, field)
|
|
52
|
-
hosts.map do |entry|
|
|
53
|
-
raise ConfigError, "#{field}.hosts entry #{entry.inspect} is not a host name, pattern, or IP range" unless entry.is_a?(String) && !entry.strip.empty?
|
|
54
|
-
|
|
55
|
-
entry.strip
|
|
56
|
-
end
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
def ip_target?(entry)
|
|
60
|
-
IPAddr.new(entry)
|
|
61
|
-
true
|
|
62
|
-
rescue IPAddr::Error
|
|
63
|
-
false
|
|
64
|
-
end
|
|
65
|
-
end
|
|
66
|
-
end
|
data/lib/lemans/patch.rb
DELETED
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "pathname"
|
|
4
|
-
require "shellwords"
|
|
5
|
-
|
|
6
|
-
module Lemans
|
|
7
|
-
# The agent's work as one git patch, diffed against a baseline sealed before
|
|
8
|
-
# its first turn
|
|
9
|
-
class Patch
|
|
10
|
-
LOCAL_PATH = "agent.patch"
|
|
11
|
-
REMOTE_PATCH = "/tmp/lemans-agent.patch"
|
|
12
|
-
REMOTE_INDEX = "/tmp/lemans-patch.idx"
|
|
13
|
-
|
|
14
|
-
TIMEOUT = 300
|
|
15
|
-
|
|
16
|
-
def initialize(environment, bench:, dir:)
|
|
17
|
-
@environment = environment
|
|
18
|
-
@workdir = bench.environment.workdir
|
|
19
|
-
@path = Pathname(dir).join(LOCAL_PATH)
|
|
20
|
-
@baseline = nil
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
def seal!
|
|
24
|
-
@baseline = write_tree
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
# Must run before the verifier restores the graded surfaces: a patch taken
|
|
28
|
-
# after would not show what the agent did to them
|
|
29
|
-
def collect!
|
|
30
|
-
return unless baseline
|
|
31
|
-
|
|
32
|
-
after = write_tree
|
|
33
|
-
return unless after
|
|
34
|
-
|
|
35
|
-
result = environment.exec("#{git} diff --binary #{baseline} #{after} > #{REMOTE_PATCH}", timeout: TIMEOUT)
|
|
36
|
-
return unless result.success?
|
|
37
|
-
|
|
38
|
-
path.dirname.mkpath
|
|
39
|
-
environment.download(REMOTE_PATCH, path)
|
|
40
|
-
environment.exec("rm -f #{REMOTE_PATCH} #{REMOTE_INDEX}", timeout: TIMEOUT)
|
|
41
|
-
path
|
|
42
|
-
rescue InfrastructureError => e
|
|
43
|
-
warn "lemans: could not collect the agent patch for #{path.dirname.basename}: #{e.message}"
|
|
44
|
-
nil
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
private
|
|
48
|
-
|
|
49
|
-
attr_reader :environment, :workdir, :path, :baseline
|
|
50
|
-
|
|
51
|
-
# `safe.directory` because the sandbox may run the tree as a different user
|
|
52
|
-
# than built it, and git refuses to read a repo it thinks is someone else's.
|
|
53
|
-
def git = "git -c safe.directory='*' -C #{Shellwords.escape(workdir)}"
|
|
54
|
-
|
|
55
|
-
# Untracked files only reach a diff through an index, so both sides are
|
|
56
|
-
# staged into a scratch one and hashed. Rebuilt from empty each time, so a
|
|
57
|
-
# stale entry cannot survive into the second tree.
|
|
58
|
-
def write_tree
|
|
59
|
-
result = environment.exec(
|
|
60
|
-
"rm -f #{REMOTE_INDEX} && GIT_INDEX_FILE=#{REMOTE_INDEX} #{git} add -A && " \
|
|
61
|
-
"GIT_INDEX_FILE=#{REMOTE_INDEX} #{git} write-tree",
|
|
62
|
-
timeout: TIMEOUT
|
|
63
|
-
)
|
|
64
|
-
return nil unless result.success?
|
|
65
|
-
|
|
66
|
-
tree = result.output.to_s.lines.map(&:strip).reject(&:empty?).last
|
|
67
|
-
tree if /\A[0-9a-f]{40,64}\z/.match?(tree.to_s)
|
|
68
|
-
end
|
|
69
|
-
end
|
|
70
|
-
end
|
data/lib/lemans/restore_paths.rb
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "pathname"
|
|
4
|
-
|
|
5
|
-
module Lemans
|
|
6
|
-
module RestorePaths # :nodoc:
|
|
7
|
-
def self.call(declared, label:)
|
|
8
|
-
Array(declared).each_with_index.map do |path, index|
|
|
9
|
-
entry = "#{label}[#{index}]"
|
|
10
|
-
raise ConfigError, "#{entry} must be a path string, got #{path.inspect}" unless path.is_a?(String)
|
|
11
|
-
raise ConfigError, "#{entry} must be workdir-relative, got #{path.inspect}" if path.start_with?("/")
|
|
12
|
-
raise ConfigError, "#{entry} must not escape the workdir: #{path.inspect}" if
|
|
13
|
-
path.split("/").include?("..")
|
|
14
|
-
raise ConfigError, "#{entry} must name something inside the workdir, got #{path.inspect}" if
|
|
15
|
-
Pathname(path).cleanpath.to_s == "."
|
|
16
|
-
|
|
17
|
-
path
|
|
18
|
-
end.freeze
|
|
19
|
-
end
|
|
20
|
-
end
|
|
21
|
-
end
|
|
@@ -1,114 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "csv"
|
|
4
|
-
|
|
5
|
-
module Lemans
|
|
6
|
-
module Results
|
|
7
|
-
# Rolls trials up the way a leaderboard quotes them: solved out of
|
|
8
|
-
# attempts, median time, mean spend per run. Groups by any 1-3 of
|
|
9
|
-
# task, agent, model — "task-model" reads as two columns.
|
|
10
|
-
class Aggregate
|
|
11
|
-
KEYS = %i[task agent model].freeze
|
|
12
|
-
METRICS = %i[score time cost steps tokens].freeze
|
|
13
|
-
METRIC_SOURCES = { time: :duration_sec, cost: :cost_usd, steps: :steps, tokens: :tokens }.freeze
|
|
14
|
-
|
|
15
|
-
attr_reader :report, :keys
|
|
16
|
-
|
|
17
|
-
def self.keys(spec)
|
|
18
|
-
keys = spec.to_s.split("-").map(&:to_sym)
|
|
19
|
-
return keys if keys.size.between?(1, 3) && keys.uniq == keys && (keys - KEYS).empty?
|
|
20
|
-
|
|
21
|
-
raise ConfigError, "--aggregate: expected 1-3 of #{KEYS.join(", ")} joined by dashes (got #{spec.inspect})"
|
|
22
|
-
end
|
|
23
|
-
|
|
24
|
-
def initialize(report, keys:)
|
|
25
|
-
@report = report
|
|
26
|
-
@keys = keys
|
|
27
|
-
@groups = report.rows
|
|
28
|
-
.group_by { |row| keys.map { row[_1] } }
|
|
29
|
-
.map { |values, group| build(values, group) }
|
|
30
|
-
.sort_by { |group| keys.map { group[_1].to_s } }
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
def order_by!(column)
|
|
34
|
-
column = Sorting.column(column, allowed: keys + METRICS)
|
|
35
|
-
@groups =
|
|
36
|
-
if keys.include?(column)
|
|
37
|
-
Sorting.call(@groups) { _1[column].to_s }
|
|
38
|
-
elsif column == :score
|
|
39
|
-
Sorting.call(@groups, descending: true) { [Rational(_1[:solved], _1[:attempts]), _1[:attempts]] }
|
|
40
|
-
else
|
|
41
|
-
Sorting.call(@groups, descending: true) { _1[METRIC_SOURCES.fetch(column)] }
|
|
42
|
-
end
|
|
43
|
-
self
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
def to_rows
|
|
47
|
-
[keys.map(&:to_s) + METRICS.map(&:to_s)] +
|
|
48
|
-
@groups.map do |group|
|
|
49
|
-
keys.map { |key| display_key(key, group[key]) } + [
|
|
50
|
-
"#{group[:solved]}/#{group[:attempts]}",
|
|
51
|
-
time(group[:duration_sec]),
|
|
52
|
-
cost(group[:cost_usd]),
|
|
53
|
-
mean_display(group[:steps], 1),
|
|
54
|
-
mean_display(group[:tokens], 0)
|
|
55
|
-
]
|
|
56
|
-
end
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
def to_csv
|
|
60
|
-
columns = keys + %i[solved attempts duration_sec cost_usd steps tokens]
|
|
61
|
-
CSV.generate do |csv|
|
|
62
|
-
csv << columns
|
|
63
|
-
@groups.each { |group| csv << columns.map { group[_1] } }
|
|
64
|
-
end
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
def summary = report.summary
|
|
68
|
-
|
|
69
|
-
def summary_lines = report.summary_lines
|
|
70
|
-
|
|
71
|
-
private
|
|
72
|
-
|
|
73
|
-
# Attempts count every run; means and the median skip runs that never
|
|
74
|
-
# measured the value, so one invalid trial cannot zero out a cell.
|
|
75
|
-
def build(values, group)
|
|
76
|
-
keys.zip(values).to_h.merge(
|
|
77
|
-
solved: Tally.call(group)[:solved],
|
|
78
|
-
attempts: group.size,
|
|
79
|
-
duration_sec: median(group.filter_map { _1[:duration_sec] }),
|
|
80
|
-
cost_usd: mean(group.filter_map { _1[:cost_usd] }),
|
|
81
|
-
steps: mean(group.filter_map { _1[:steps] }),
|
|
82
|
-
tokens: mean(group.filter_map { _1[:tokens] })
|
|
83
|
-
)
|
|
84
|
-
end
|
|
85
|
-
|
|
86
|
-
def mean(values) = values.empty? ? nil : values.sum(0.0) / values.size
|
|
87
|
-
|
|
88
|
-
def median(values)
|
|
89
|
-
return nil if values.empty?
|
|
90
|
-
|
|
91
|
-
sorted = values.sort
|
|
92
|
-
mid = sorted.size / 2
|
|
93
|
-
sorted.size.odd? ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2.0
|
|
94
|
-
end
|
|
95
|
-
|
|
96
|
-
def display_key(key, value)
|
|
97
|
-
return "-" if value.nil?
|
|
98
|
-
|
|
99
|
-
key == :model ? Report.short_model(value) : value.to_s
|
|
100
|
-
end
|
|
101
|
-
|
|
102
|
-
def time(sec)
|
|
103
|
-
return "-" if sec.nil?
|
|
104
|
-
|
|
105
|
-
minutes, seconds = sec.round.divmod(60)
|
|
106
|
-
minutes.positive? ? "#{minutes}m #{seconds}s" : "#{seconds}s"
|
|
107
|
-
end
|
|
108
|
-
|
|
109
|
-
def cost(value) = value.nil? ? "-" : "$#{format("%g", value.round(4))}"
|
|
110
|
-
|
|
111
|
-
def mean_display(value, digits) = value.nil? ? "-" : format("%g", value.round(digits))
|
|
112
|
-
end
|
|
113
|
-
end
|
|
114
|
-
end
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Lemans
|
|
4
|
-
module Results
|
|
5
|
-
# Where a trial's dollar figure came from: a published $0.00 is only worth
|
|
6
|
-
# reading if it can be told apart from "nobody could price this model".
|
|
7
|
-
CostSource = Data.define(:name, :model, :priced_as, :registry) do
|
|
8
|
-
def self.none = new(name: :none, model: nil, priced_as: nil, registry: nil)
|
|
9
|
-
|
|
10
|
-
def to_h = { name: name, model: model, priced_as: priced_as, registry: registry }.compact
|
|
11
|
-
end
|
|
12
|
-
end
|
|
13
|
-
end
|
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Lemans
|
|
4
|
-
module Results
|
|
5
|
-
# Why a trial ended, and whether its reward means anything: out-of-budget
|
|
6
|
-
# is a scored failure, a sandbox that never started measured nothing.
|
|
7
|
-
class Outcome
|
|
8
|
-
SCORED = %i[completed agent_timeout step_limit_reached cost_ceiling_reached].freeze
|
|
9
|
-
INVALID = %i[environment_error agent_error accounting_error verifier_error cancelled harness_crash].freeze
|
|
10
|
-
|
|
11
|
-
ALL = (SCORED + INVALID).freeze
|
|
12
|
-
|
|
13
|
-
attr_reader :name, :detail
|
|
14
|
-
|
|
15
|
-
def initialize(name, detail: nil)
|
|
16
|
-
raise ArgumentError, "unknown outcome #{name.inspect}" unless ALL.include?(name)
|
|
17
|
-
|
|
18
|
-
@name = name
|
|
19
|
-
@detail = detail
|
|
20
|
-
freeze
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
ALL.each do |outcome|
|
|
24
|
-
define_method(:"#{outcome}?") { name == outcome }
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
def scored? = SCORED.include?(name)
|
|
28
|
-
|
|
29
|
-
def invalid? = !scored?
|
|
30
|
-
|
|
31
|
-
def to_h = { name: name, scored: scored?, detail: detail }.compact
|
|
32
|
-
|
|
33
|
-
def to_s = detail ? "#{name}: #{detail}" : name.to_s
|
|
34
|
-
end
|
|
35
|
-
end
|
|
36
|
-
end
|