lemans 1.5.0 → 1.5.2
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 +14 -0
- data/README.md +86 -1
- data/exe/lemans-remote +64 -8
- data/exe/lemans-viewer +45 -9
- data/lib/lemans/agent.rb +5 -4
- data/lib/lemans/agents.rb +13 -5
- data/lib/lemans/cli/regrade.rb +29 -77
- data/lib/lemans/cli/report/aggregate.rb +61 -34
- data/lib/lemans/cli/report.rb +183 -29
- data/lib/lemans/cli.rb +53 -10
- data/lib/lemans/environments/daytona/retries.rb +2 -2
- data/lib/lemans/environments/daytona.rb +7 -1
- data/lib/lemans/environments/docker/proxy/README.md +1 -1
- data/lib/lemans/environments/docker/proxy/server.rb +2 -1
- data/lib/lemans/environments/docker.rb +5 -1
- data/lib/lemans/environments.rb +14 -4
- data/lib/lemans/result.rb +14 -6
- data/lib/lemans/stores/fs.rb +5 -4
- data/lib/lemans/trial/verifier/test_scanner.rb +136 -0
- data/lib/lemans/trial/verifier.rb +19 -10
- data/lib/lemans/trial.rb +2 -1
- data/lib/lemans/version.rb +1 -1
- data/lib/miniswen/agent.rb +20 -9
- data/lib/miniswen/ruby_llm.rb +13 -0
- data/lib/miniswen/version.rb +1 -1
- metadata +2 -1
data/lib/lemans/cli.rb
CHANGED
|
@@ -13,8 +13,26 @@ module Lemans
|
|
|
13
13
|
|
|
14
14
|
source_root File.expand_path("cli/templates", __dir__)
|
|
15
15
|
|
|
16
|
+
class_option :require, aliases: "-r", banner: "FILE", repeatable: true,
|
|
17
|
+
desc: "Load a Ruby file first, e.g. one that registers an agent or a backend"
|
|
18
|
+
|
|
16
19
|
def self.exit_on_failure? = true
|
|
17
20
|
|
|
21
|
+
# `lemans -r plugin.rb run` reads as `lemans run -r plugin.rb`
|
|
22
|
+
def self.start(given_args = ARGV, config = {})
|
|
23
|
+
args = given_args.dup
|
|
24
|
+
leading = []
|
|
25
|
+
while (flag = args.first)&.match?(/\A(-r|--require)(=|\z)/)
|
|
26
|
+
leading.push(*args.shift(flag.include?("=") ? 1 : 2))
|
|
27
|
+
end
|
|
28
|
+
super(args.insert([ args.size, 1 ].min, *leading), config)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def initialize(*)
|
|
32
|
+
super
|
|
33
|
+
Array(options[:require]).each { require File.expand_path(it) }
|
|
34
|
+
end
|
|
35
|
+
|
|
18
36
|
map %w[-v --version] => :version
|
|
19
37
|
desc "version", "Print the lemans version"
|
|
20
38
|
def version
|
|
@@ -48,14 +66,14 @@ module Lemans
|
|
|
48
66
|
option :bench, default: ".", desc: "Directory holding bench.yml"
|
|
49
67
|
option :task, desc: "Run task(s) by name", repeatable: true
|
|
50
68
|
option :tag, desc: "Run every task carrying this tag(s)", repeatable: true
|
|
51
|
-
option :agent, desc: "Override the agent from bench.yml (miniswen, miniswen-installed, oracle, nop)"
|
|
69
|
+
option :agent, desc: "Override the agent from bench.yml (miniswen, miniswen-installed, oracle, nop, or one registered by --require)"
|
|
52
70
|
option :model, desc: "Override the model(s) from bench.yml", repeatable: true
|
|
53
71
|
option :max_output_tokens, type: :numeric, banner: "TOKENS",
|
|
54
72
|
desc: "Cap the agent's output per model call (default: the provider's)"
|
|
55
73
|
option :attempts, type: :numeric, default: 1, aliases: "-k", desc: "Trials per task"
|
|
56
74
|
option :concurrency, type: :numeric, default: 4, aliases: "-c", desc: "Trials in flight at once"
|
|
57
75
|
option :runs_dir, default: "./runs", desc: "Where to write run directories"
|
|
58
|
-
option :backend,
|
|
76
|
+
option :backend, desc: "Sandbox backend: daytona (default), docker, or one registered by --require"
|
|
59
77
|
option :resume, type: :boolean, default: false, desc: "Skip trials that already have a result"
|
|
60
78
|
def run_bench
|
|
61
79
|
# The bundled pricing registry ages faster than the gem: refresh once up
|
|
@@ -96,7 +114,7 @@ module Lemans
|
|
|
96
114
|
option :bench, default: ".", desc: "Directory holding bench.yml"
|
|
97
115
|
option :runs_dir, default: "./runs", desc: "Directory holding the runs; the new runs go there too"
|
|
98
116
|
option :concurrency, type: :numeric, aliases: "-c", desc: "Restarts in flight at once (default: the bench's)"
|
|
99
|
-
option :backend,
|
|
117
|
+
option :backend, desc: "Sandbox backend: daytona (default), docker, or one registered by --require"
|
|
100
118
|
option :max_output_tokens, type: :numeric, banner: "TOKENS",
|
|
101
119
|
desc: "Cap the agent's output per model call (default: the provider's)"
|
|
102
120
|
option :recover, type: :boolean, default: false,
|
|
@@ -175,12 +193,18 @@ module Lemans
|
|
|
175
193
|
tasks.each do |task|
|
|
176
194
|
mapping = options[:mapping] ? Regrade.mapping_from_file(options[:mapping]) : Regrade.mapping_for(task)
|
|
177
195
|
regrade = Regrade.new(store, task.name, mapping:)
|
|
196
|
+
mapping.stray_features.to_a.each { say_status :warning, "#{it}: @feature comment outside any test", :yellow }
|
|
178
197
|
regrade.verify_mapping! unless options[:mapping]
|
|
179
198
|
|
|
180
199
|
changes, skipped = regrade.execute!
|
|
181
200
|
changes.each { say_status :regraded, "#{it.result.id} #{grade_change(it)}", :green }
|
|
182
201
|
skipped.each { |result, reason| say_status :skipped, "#{result.id} #{reason}", :yellow }
|
|
183
202
|
say "#{task.name}: #{changes.size} re-graded, #{skipped.size} skipped"
|
|
203
|
+
|
|
204
|
+
next unless task.multistep?
|
|
205
|
+
|
|
206
|
+
counted = regrade.record_total_steps!(task.steps)
|
|
207
|
+
say "#{task.name}: #{counted.size} run(s) gained total_steps: #{task.steps}" if counted.any?
|
|
184
208
|
end
|
|
185
209
|
|
|
186
210
|
say ""
|
|
@@ -191,19 +215,28 @@ module Lemans
|
|
|
191
215
|
end
|
|
192
216
|
|
|
193
217
|
desc "report [RUNS_DIR]", "Summarize run results as a table or CSV"
|
|
194
|
-
option :runs_dir, default:
|
|
218
|
+
option :runs_dir, default: [], desc: "Directories holding run results (or pass it as RUNS_DIR)", repeatable: true
|
|
195
219
|
option :tag, desc: "Only runs whose result carries this tag", repeatable: true
|
|
196
220
|
option :task, desc: "Only these tasks' runs", repeatable: true
|
|
221
|
+
option :model, desc: "Only these models' runs (full id or the short name the table shows)", repeatable: true
|
|
197
222
|
option :metadata, banner: "KEY:VALUE", desc: "Only runs whose task metadata has this value (every pair must match)",
|
|
198
223
|
repeatable: true
|
|
199
224
|
option :format, default: "table", enum: %w[table csv], desc: "Output format"
|
|
200
225
|
option :aggregate, aliases: "-A", banner: "COLUMNS", lazy_default: "task-model",
|
|
201
226
|
desc: "Group results by 1-3 dash-joined columns (task, agent, model)"
|
|
202
|
-
option :sort, aliases: "-S", banner: "
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
227
|
+
option :sort, aliases: "-S", banner: "COLUMNS",
|
|
228
|
+
desc: "Sort by dash-joined columns, e.g. score-credit (numbers high to low, names A-Z; ^column reverses)"
|
|
229
|
+
option :skip_invalid, type: :boolean, default: false, desc: "Leave out trials with an invalid outcome"
|
|
230
|
+
option :show_features, type: :boolean, default: false, desc: "Add a feat:<name> column per tracked feature"
|
|
231
|
+
option :hide_columns, banner: "COLUMNS", desc: "Leave dash-joined columns out of the table, e.g. steps-tokens-trial"
|
|
232
|
+
def report(runs_dir = nil)
|
|
233
|
+
runs_dir = ([ runs_dir ] + options[:runs_dir]).compact
|
|
234
|
+
runs_dir = [ "runs" ] if runs_dir.empty?
|
|
235
|
+
store = Stores::FS.new(*runs_dir)
|
|
236
|
+
results = Report.load(store, tags: options[:tag], names: options[:task], models: options[:model],
|
|
237
|
+
metadata: Report.metadata_filter(options[:metadata]),
|
|
238
|
+
skip_invalid: options[:skip_invalid], show_features: options[:show_features],
|
|
239
|
+
hide_columns: options[:hide_columns])
|
|
207
240
|
raise Thor::Error, "lemans: no matching results found" if results.empty?
|
|
208
241
|
|
|
209
242
|
results = Report::Aggregate.new(results, keys: Report::Aggregate.keys(options[:aggregate])) if options[:aggregate]
|
|
@@ -253,9 +286,19 @@ module Lemans
|
|
|
253
286
|
end
|
|
254
287
|
|
|
255
288
|
def grade_change(change)
|
|
256
|
-
%i[reward credit].map { |grade| "#{grade} #{change[grade].map(&:inspect).join(" -> ")}" }
|
|
289
|
+
grades = %i[reward credit].map { |grade| "#{grade} #{change[grade].map(&:inspect).join(" -> ")}" }
|
|
290
|
+
before, after = change.features
|
|
291
|
+
return grades.join(" ") if before == after
|
|
292
|
+
|
|
293
|
+
features = (before.to_h.keys | after.to_h.keys).sort.filter_map do |name|
|
|
294
|
+
was, now = [ before, after ].map { feature_mark(it&.fetch(name, nil)) }
|
|
295
|
+
"#{name} #{was} -> #{now}" if was != now
|
|
296
|
+
end
|
|
297
|
+
[ *grades, "features #{features.join(", ")}" ].join(" ")
|
|
257
298
|
end
|
|
258
299
|
|
|
300
|
+
def feature_mark(passed) = { true => "✓", false => "✗" }.fetch(passed, "-")
|
|
301
|
+
|
|
259
302
|
def print_report(report)
|
|
260
303
|
print_table report.to_rows
|
|
261
304
|
color = report.summary[:invalid].positive? ? :red : nil
|
|
@@ -19,13 +19,13 @@ module Lemans
|
|
|
19
19
|
|
|
20
20
|
private
|
|
21
21
|
|
|
22
|
-
def with_read_retries
|
|
22
|
+
def with_read_retries(conflicts: false)
|
|
23
23
|
attempts = 0
|
|
24
24
|
begin
|
|
25
25
|
yield
|
|
26
26
|
rescue *SDK_ERRORS => e
|
|
27
27
|
attempts += 1
|
|
28
|
-
raise if attempts >= READ_ATTEMPTS || !retryable?(e)
|
|
28
|
+
raise if attempts >= READ_ATTEMPTS || !(retryable?(e) || (conflicts && status_code(e) == 409))
|
|
29
29
|
|
|
30
30
|
sleep RETRY_DELAY_SEC * 2**(attempts - 1)
|
|
31
31
|
retry
|
|
@@ -90,8 +90,14 @@ module Lemans
|
|
|
90
90
|
raise InfrastructureError, "daytona: could not download #{remote_path}: #{e.message}"
|
|
91
91
|
end
|
|
92
92
|
|
|
93
|
+
# Daytona answers 409 while an earlier operation on the sandbox is still
|
|
94
|
+
# settling; a policy update sets the whole policy, so it repeats freely.
|
|
93
95
|
def switch_network_policy!(policy)
|
|
94
|
-
|
|
96
|
+
return if policy.to_h == network.to_h
|
|
97
|
+
|
|
98
|
+
with_read_retries(conflicts: true) do
|
|
99
|
+
sandbox.update_network_settings(**network_kwargs(policy, for_update: true))
|
|
100
|
+
end
|
|
95
101
|
@network = policy
|
|
96
102
|
rescue *Retries::SDK_ERRORS => e
|
|
97
103
|
raise InfrastructureError, "daytona: could not apply #{policy.mode} policy: #{e.message}"
|
|
@@ -7,7 +7,7 @@ The proxy supports two request types:
|
|
|
7
7
|
- `CONNECT host:port` tunnels (https).
|
|
8
8
|
- Absolute-form requests (`GET http://host/path`) for plain http.
|
|
9
9
|
|
|
10
|
-
The proxy refuses a host that is not on the list with `403 Forbidden`. Host names must match exactly. IP ranges are not supported.
|
|
10
|
+
The proxy refuses a host that is not on the list with `403 Forbidden`. Host names must match exactly. IP ranges are not supported. The host `*` lets every host through.
|
|
11
11
|
|
|
12
12
|
## How the Docker backend uses it
|
|
13
13
|
|
|
@@ -5,6 +5,7 @@ require "uri"
|
|
|
5
5
|
|
|
6
6
|
# An HTTP proxy that lets clients reach the allowed hosts only:
|
|
7
7
|
# CONNECT tunnels for https, absolute-form requests for plain http.
|
|
8
|
+
# A "*" host lets every host through.
|
|
8
9
|
class AllowlistProxy
|
|
9
10
|
HOP_HEADERS = /\A(?:connection|keep-alive|proxy-[\w-]+):/i
|
|
10
11
|
|
|
@@ -55,7 +56,7 @@ class AllowlistProxy
|
|
|
55
56
|
pump(client, upstream)
|
|
56
57
|
end
|
|
57
58
|
|
|
58
|
-
def allowed?(host) = @hosts.include?(host.to_s.downcase)
|
|
59
|
+
def allowed?(host) = @hosts.include?("*") || @hosts.include?(host.to_s.downcase)
|
|
59
60
|
|
|
60
61
|
def deny(client) = client.write("HTTP/1.1 403 Forbidden\r\n\r\n")
|
|
61
62
|
|
|
@@ -16,6 +16,10 @@ module Lemans
|
|
|
16
16
|
|
|
17
17
|
attr_reader :container
|
|
18
18
|
|
|
19
|
+
def self.proxy_image
|
|
20
|
+
@proxy_image ||= Config::ImageSpec.dockerfile(Pathname(__dir__).join("docker/proxy/Dockerfile"), slug: "proxy")
|
|
21
|
+
end
|
|
22
|
+
|
|
19
23
|
def initialize(image:, resources:, network:, env: {}, labels: {}, logger: nil, build_timeout: nil, ttl: nil)
|
|
20
24
|
super(image:, resources:, network:, env:, labels:, ttl:,
|
|
21
25
|
build_timeout: build_timeout || DEFAULT_BUILD_TIMEOUT)
|
|
@@ -108,7 +112,7 @@ module Lemans
|
|
|
108
112
|
# The task container sits on an internal network with no route out; the
|
|
109
113
|
# proxy is on it and on bridge, so the hosts it allows are the only way out.
|
|
110
114
|
def start_proxy!(hosts)
|
|
111
|
-
image =
|
|
115
|
+
image = self.class.proxy_image
|
|
112
116
|
build_image!(image)
|
|
113
117
|
create_internal_network!
|
|
114
118
|
docker!("run", "--detach", "--name", proxy_name, "--network", internal_network,
|
data/lib/lemans/environments.rb
CHANGED
|
@@ -6,11 +6,21 @@ module Lemans
|
|
|
6
6
|
module Environments
|
|
7
7
|
BACKENDS = { "daytona" => "Daytona", "docker" => "Docker" }.freeze
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
constant = BACKENDS[backend.to_s] or
|
|
11
|
-
raise ConfigError, "unknown backend #{backend.inspect} (known: #{BACKENDS.keys.join(", ")})"
|
|
9
|
+
@registered = {}
|
|
12
10
|
|
|
13
|
-
|
|
11
|
+
class << self
|
|
12
|
+
def register(name, environment_class) = @registered[name] = environment_class
|
|
13
|
+
|
|
14
|
+
def unregister(name) = @registered.delete(name)
|
|
15
|
+
|
|
16
|
+
def names = BACKENDS.keys | @registered.keys
|
|
17
|
+
|
|
18
|
+
def build(backend, **) = lookup(backend.to_s).new(**)
|
|
19
|
+
|
|
20
|
+
def lookup(name)
|
|
21
|
+
@registered[name] || (BACKENDS[name] && const_get(BACKENDS[name])) or
|
|
22
|
+
raise ConfigError, "unknown backend #{name.inspect} (known: #{names.join(", ")})"
|
|
23
|
+
end
|
|
14
24
|
end
|
|
15
25
|
end
|
|
16
26
|
end
|
data/lib/lemans/result.rb
CHANGED
|
@@ -156,12 +156,12 @@ module Lemans
|
|
|
156
156
|
attr_reader :id, :task, :agent, :model, :index,
|
|
157
157
|
:profile_digest, :task_digest, :revision
|
|
158
158
|
|
|
159
|
-
attr_accessor :tags, :metadata, :restarted_from
|
|
159
|
+
attr_accessor :tags, :metadata, :restarted_from, :total_steps
|
|
160
160
|
|
|
161
161
|
attr_reader :phases, :steps
|
|
162
162
|
|
|
163
163
|
# outcome-related attributes (we use setter-like methods, not accessors)
|
|
164
|
-
attr_reader :reward, :credit, :outcome, :usage
|
|
164
|
+
attr_reader :reward, :credit, :features, :outcome, :usage
|
|
165
165
|
|
|
166
166
|
def initialize(task:, agent:, model:, id: nil, index: nil,
|
|
167
167
|
profile_digest: nil, task_digest: nil, revision: nil)
|
|
@@ -177,6 +177,7 @@ module Lemans
|
|
|
177
177
|
@metadata = {}
|
|
178
178
|
@phases = []
|
|
179
179
|
@steps = nil
|
|
180
|
+
@total_steps = nil
|
|
180
181
|
@restarted_from = nil
|
|
181
182
|
|
|
182
183
|
@id = id || "#{task}__#{SecureRandom.alphanumeric(7)}"
|
|
@@ -229,9 +230,10 @@ module Lemans
|
|
|
229
230
|
completed!(outcome, aggregate_usage)
|
|
230
231
|
end
|
|
231
232
|
|
|
232
|
-
def graded!(reward, credit: reward)
|
|
233
|
+
def graded!(reward, credit: reward, features: nil)
|
|
233
234
|
@reward = reward
|
|
234
235
|
@credit = credit
|
|
236
|
+
@features = features
|
|
235
237
|
self
|
|
236
238
|
end
|
|
237
239
|
|
|
@@ -243,6 +245,7 @@ module Lemans
|
|
|
243
245
|
@outcome = Outcome.new(reason, detail)
|
|
244
246
|
@reward = nil
|
|
245
247
|
@credit = nil
|
|
248
|
+
@features = nil
|
|
246
249
|
self
|
|
247
250
|
end
|
|
248
251
|
|
|
@@ -297,8 +300,8 @@ module Lemans
|
|
|
297
300
|
restarted_from: restarted_from&.as_json,
|
|
298
301
|
lemans_version: VERSION,
|
|
299
302
|
tags:, metadata:, phases: phases.map(&:as_json),
|
|
300
|
-
steps: steps&.map(&:as_json),
|
|
301
|
-
reward:, credit:, outcome: outcome.as_json, usage: usage&.as_json, duration:,
|
|
303
|
+
steps: steps&.map(&:as_json), total_steps:,
|
|
304
|
+
reward:, credit:, features:, outcome: outcome.as_json, usage: usage&.as_json, duration:,
|
|
302
305
|
started_at: started_at&.iso8601,
|
|
303
306
|
finished_at: finished_at&.iso8601
|
|
304
307
|
}.compact
|
|
@@ -315,6 +318,7 @@ module Lemans
|
|
|
315
318
|
result.tags = data[:tags] || []
|
|
316
319
|
result.metadata = data[:metadata] || {}
|
|
317
320
|
result.restarted_from = Restart.new(**data[:restarted_from]) if data[:restarted_from]
|
|
321
|
+
result.total_steps = data[:total_steps]
|
|
318
322
|
phases_from(data).each { result.phases << it }
|
|
319
323
|
|
|
320
324
|
# Steps first: the stored outcome/usage below override the aggregates.
|
|
@@ -332,7 +336,10 @@ module Lemans
|
|
|
332
336
|
)
|
|
333
337
|
end
|
|
334
338
|
|
|
335
|
-
|
|
339
|
+
unless data[:reward].nil?
|
|
340
|
+
result.graded!(data[:reward], credit: data[:credit] || data[:reward],
|
|
341
|
+
features: data[:features]&.transform_keys(&:to_s))
|
|
342
|
+
end
|
|
336
343
|
result
|
|
337
344
|
end
|
|
338
345
|
|
|
@@ -349,6 +356,7 @@ module Lemans
|
|
|
349
356
|
)
|
|
350
357
|
result.tags = definition.tags
|
|
351
358
|
result.metadata = definition.metadata
|
|
359
|
+
result.total_steps = definition.steps if definition.multistep?
|
|
352
360
|
result
|
|
353
361
|
end
|
|
354
362
|
|
data/lib/lemans/stores/fs.rb
CHANGED
|
@@ -11,11 +11,12 @@ module Lemans
|
|
|
11
11
|
class FS < Store
|
|
12
12
|
FILENAME = "result.json"
|
|
13
13
|
|
|
14
|
-
private attr_reader :root, :filterer
|
|
14
|
+
private attr_reader :root, :read_roots, :filterer
|
|
15
15
|
|
|
16
|
-
def initialize(root, filterer: nil)
|
|
16
|
+
def initialize(root, *read_roots, filterer: nil)
|
|
17
17
|
super()
|
|
18
18
|
@root = Pathname(root)
|
|
19
|
+
@read_roots = [ @root ] + read_roots.map { Pathname(it) }
|
|
19
20
|
@filterer = filterer
|
|
20
21
|
end
|
|
21
22
|
|
|
@@ -26,7 +27,7 @@ module Lemans
|
|
|
26
27
|
end
|
|
27
28
|
|
|
28
29
|
def fetch
|
|
29
|
-
|
|
30
|
+
read_roots.flat_map { it.glob("**/#{FILENAME}") }.filter_map { to_record(it) }
|
|
30
31
|
end
|
|
31
32
|
|
|
32
33
|
# The file system keeps no index, so filtering happens in memory.
|
|
@@ -43,7 +44,7 @@ module Lemans
|
|
|
43
44
|
RESULT_ID = /\A(?<task>.+)__[A-Za-z0-9]{7}\z/
|
|
44
45
|
|
|
45
46
|
def unreadable
|
|
46
|
-
|
|
47
|
+
read_roots.flat_map { it.glob("**/#{FILENAME}") }.filter_map do |path|
|
|
47
48
|
next if to_record(path)
|
|
48
49
|
|
|
49
50
|
id = path.dirname.basename.to_s
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
require "prism"
|
|
5
|
+
|
|
6
|
+
module Lemans
|
|
7
|
+
class Trial
|
|
8
|
+
class Verifier
|
|
9
|
+
# Reads the grading schema off the test files without running them: every
|
|
10
|
+
# `def test_*` and ActiveSupport `test "..."` is a check, an
|
|
11
|
+
# `allow_failure` call inside makes it an extra worth its `points:`, and
|
|
12
|
+
# a `# @feature <name>` comment right above a check or inside it tracks
|
|
13
|
+
# the check as that feature. Files required from the test file's
|
|
14
|
+
# directory are read too: suites often live apart.
|
|
15
|
+
class TestScanner < Prism::Visitor
|
|
16
|
+
ENTRY = "verification_test.rb"
|
|
17
|
+
FEATURE = /\A#\s*@feature\s+(\S+)/
|
|
18
|
+
|
|
19
|
+
Span = Data.define(:check, :file, :lines)
|
|
20
|
+
|
|
21
|
+
attr_reader :tests, :points, :features, :base_credit, :stray_features
|
|
22
|
+
|
|
23
|
+
# The task's verification_test.rb scanned, nil when it has none
|
|
24
|
+
def self.for(task)
|
|
25
|
+
local, = task.test_files.find { |_, remote| remote == ENTRY }
|
|
26
|
+
return unless local
|
|
27
|
+
|
|
28
|
+
new(Pathname(local).dirname).tap { it.scan(Pathname(local)) }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def initialize(root)
|
|
32
|
+
super()
|
|
33
|
+
@root = root
|
|
34
|
+
@scope = []
|
|
35
|
+
@tests = []
|
|
36
|
+
@points = {}
|
|
37
|
+
@features = {}
|
|
38
|
+
@stray_features = []
|
|
39
|
+
@spans = []
|
|
40
|
+
@current = nil
|
|
41
|
+
@scanned = []
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def scan(path)
|
|
45
|
+
return if @scanned.include?(path)
|
|
46
|
+
|
|
47
|
+
@scanned << path
|
|
48
|
+
file = @file
|
|
49
|
+
begin
|
|
50
|
+
@file = path
|
|
51
|
+
parsed = Prism.parse_file(path.to_s)
|
|
52
|
+
parsed.value.accept(self)
|
|
53
|
+
attach_features(path, parsed.comments)
|
|
54
|
+
ensure
|
|
55
|
+
@file = file
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def visit_module_node(node) = scoped(node) { super }
|
|
60
|
+
|
|
61
|
+
def visit_class_node(node) = scoped(node) { super }
|
|
62
|
+
|
|
63
|
+
def visit_def_node(node)
|
|
64
|
+
return super unless node.name.start_with?("test_")
|
|
65
|
+
|
|
66
|
+
within("#{@scope.join("::")}##{node.name}", node) { super }
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def visit_call_node(node)
|
|
70
|
+
case node.name
|
|
71
|
+
when :test
|
|
72
|
+
title = node.arguments&.arguments&.first
|
|
73
|
+
if node.receiver.nil? && node.block && title.is_a?(Prism::StringNode)
|
|
74
|
+
return within("#{@scope.join("::")}#test_#{title.unescaped.gsub(/\s+/, "_")}", node) { super }
|
|
75
|
+
end
|
|
76
|
+
when :allow_failure
|
|
77
|
+
@points[@current] ||= points_of(node) if @current
|
|
78
|
+
when :require, :require_relative
|
|
79
|
+
required = node.arguments&.arguments&.first
|
|
80
|
+
scan_required(node.name, required.unescaped) if node.receiver.nil? && required.is_a?(Prism::StringNode)
|
|
81
|
+
return
|
|
82
|
+
when :base_credit=
|
|
83
|
+
@base_credit = node.arguments.arguments.first.value if node.receiver.is_a?(Prism::ConstantReadNode) && node.receiver.name == :LemansReport
|
|
84
|
+
end
|
|
85
|
+
super
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def scan_required(how, name)
|
|
91
|
+
path = (how == :require_relative ? @file.dirname : @root).join("#{name.delete_suffix(".rb")}.rb")
|
|
92
|
+
scan(path) if path.file?
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def scoped(node)
|
|
96
|
+
@scope.push(node.constant_path.full_name)
|
|
97
|
+
yield
|
|
98
|
+
ensure
|
|
99
|
+
@scope.pop
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def within(check, node)
|
|
103
|
+
@tests << check
|
|
104
|
+
@spans << Span.new(check:, file: @file, lines: node.location.start_line..node.location.end_line)
|
|
105
|
+
@current = check
|
|
106
|
+
yield
|
|
107
|
+
ensure
|
|
108
|
+
@current = nil
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def points_of(node)
|
|
112
|
+
keywords = node.arguments&.arguments&.grep(Prism::KeywordHashNode)&.first
|
|
113
|
+
pair = keywords&.elements&.find { it.is_a?(Prism::AssocNode) && it.key.is_a?(Prism::SymbolNode) && it.key.unescaped == "points" }
|
|
114
|
+
pair ? pair.value.value : 1
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# A comment belongs to the check it sits in, or to the one its
|
|
118
|
+
# comment block opens onto.
|
|
119
|
+
def attach_features(path, comments)
|
|
120
|
+
own_line = path.readlines.each_with_index.filter_map { |text, index| index + 1 if text.lstrip.start_with?("#") }
|
|
121
|
+
spans = @spans.select { it.file == path }
|
|
122
|
+
|
|
123
|
+
comments.each do |comment|
|
|
124
|
+
name = comment.slice[FEATURE, 1] or next
|
|
125
|
+
line = comment.location.start_line
|
|
126
|
+
span = spans.find { it.lines.cover?(line) } ||
|
|
127
|
+
spans.find { |s| s.lines.begin > line && (line...s.lines.begin).all? { own_line.include?(it) } }
|
|
128
|
+
next @stray_features << "#{path}:#{line}" unless span
|
|
129
|
+
|
|
130
|
+
(@features[name] ||= []) << span.check unless @features[name]&.include?(span.check)
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -10,7 +10,7 @@ module Lemans
|
|
|
10
10
|
# Verifies a trial in the sandbox the agent worked in, after Trial has closed
|
|
11
11
|
# its network. The tests are uploaded fresh at verification time, never before.
|
|
12
12
|
class Verifier
|
|
13
|
-
Verification = Data.define(:reward, :credit, :logs)
|
|
13
|
+
Verification = Data.define(:reward, :credit, :features, :logs)
|
|
14
14
|
|
|
15
15
|
REWARD_RANGE = (0.0..1.0)
|
|
16
16
|
|
|
@@ -22,6 +22,13 @@ module Lemans
|
|
|
22
22
|
|
|
23
23
|
VERIFY_BIN = "verify"
|
|
24
24
|
|
|
25
|
+
# A feature passes when all its checks passed; one with a check the run
|
|
26
|
+
# never reported is left out.
|
|
27
|
+
def self.features_from(statuses, features)
|
|
28
|
+
graded = features.to_h.select { |_, checks| checks.all? { statuses.key?(it) } }
|
|
29
|
+
graded.transform_values { |checks| checks.all? { statuses[it] == "pass" } }.sort.to_h unless graded.empty?
|
|
30
|
+
end
|
|
31
|
+
|
|
25
32
|
private attr_reader :task, :environment, :snapshot, :timeout
|
|
26
33
|
|
|
27
34
|
def initialize(task, environment, snapshot)
|
|
@@ -94,7 +101,9 @@ module Lemans
|
|
|
94
101
|
result = environment.exec(command, timeout:, env:)
|
|
95
102
|
|
|
96
103
|
reward = read_reward(result)
|
|
97
|
-
|
|
104
|
+
checks = read_checks
|
|
105
|
+
features = (self.class.features_from(checks.fetch("checks", {}), TestScanner.for(task)&.features) if checks && task.final_step?)
|
|
106
|
+
Verification.new(reward:, credit: credit_from(checks, reward), features:, logs: result.output.to_s)
|
|
98
107
|
end
|
|
99
108
|
|
|
100
109
|
def verifier_script
|
|
@@ -120,20 +129,20 @@ module Lemans
|
|
|
120
129
|
value
|
|
121
130
|
end
|
|
122
131
|
|
|
123
|
-
def
|
|
132
|
+
def read_checks
|
|
124
133
|
path = File.join(task.verifier.logs_dir, "checks.json")
|
|
125
|
-
return
|
|
134
|
+
return nil unless environment.exec("test -e #{Shellwords.escape(path)}").success?
|
|
126
135
|
|
|
127
136
|
result = environment.exec("cat #{Shellwords.escape(path)}")
|
|
128
137
|
raise VerifierError, "could not read #{path}: #{result.output.to_s[0, 500]}" unless result.success?
|
|
129
138
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
end
|
|
139
|
+
JSON.parse(result.output.to_s)
|
|
140
|
+
rescue JSON::ParserError => e
|
|
141
|
+
raise VerifierError, "#{path} is not JSON: #{e.message[0, 500]}"
|
|
142
|
+
end
|
|
135
143
|
|
|
136
|
-
|
|
144
|
+
def credit_from(checks, reward)
|
|
145
|
+
grading = checks&.dig("grading")
|
|
137
146
|
return reward unless grading && (base_credit = grading["base_credit"])
|
|
138
147
|
return 0.0 if reward.zero?
|
|
139
148
|
|
data/lib/lemans/trial.rb
CHANGED
|
@@ -102,6 +102,7 @@ module Lemans
|
|
|
102
102
|
# Whatever the agent brought back is evidence, a failed run's included
|
|
103
103
|
save_trajectory!(response.trajectory)
|
|
104
104
|
store&.save_artifact(result, response.raw_result, path: with_step_index("agent.result.json")) if response.raw_result
|
|
105
|
+
response.artifacts.each { |path, contents| store&.save_artifact(result, contents, path: with_step_index(path)) }
|
|
105
106
|
|
|
106
107
|
if response.error?
|
|
107
108
|
result.failed!(:agent_error, response.error)
|
|
@@ -139,7 +140,7 @@ module Lemans
|
|
|
139
140
|
store&.save_artifact(result, verification.logs, path: with_step_index("verifier.log"))
|
|
140
141
|
|
|
141
142
|
if step_task.final_step?
|
|
142
|
-
result.graded!(verification.reward, credit: verification.credit)
|
|
143
|
+
result.graded!(verification.reward, credit: verification.credit, features: verification.features)
|
|
143
144
|
elsif verification.reward.zero?
|
|
144
145
|
result.graded!(0.0)
|
|
145
146
|
throw :halt
|
data/lib/lemans/version.rb
CHANGED