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.
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, enum: Environments::BACKENDS.keys, desc: "Sandbox backend (default: daytona)"
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, enum: Environments::BACKENDS.keys, desc: "Sandbox backend (default: daytona)"
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: "runs", desc: "Directory holding run directories (or pass it as RUNS_DIR)"
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: "COLUMN", desc: "Sort by a column"
203
- def report(runs_dir = options[:runs_dir])
204
- store = Stores::FS.new(runs_dir)
205
- results = Report.load(store, tags: options[:tag], names: options[:task],
206
- metadata: Report.metadata_filter(options[:metadata]))
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(" -> ")}" }.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
- sandbox.update_network_settings(**network_kwargs(policy, for_update: true))
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 = Config::ImageSpec.dockerfile(Pathname(__dir__).join("docker/proxy/Dockerfile"), slug: "proxy")
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,
@@ -6,11 +6,21 @@ module Lemans
6
6
  module Environments
7
7
  BACKENDS = { "daytona" => "Daytona", "docker" => "Docker" }.freeze
8
8
 
9
- def self.build(backend, **)
10
- constant = BACKENDS[backend.to_s] or
11
- raise ConfigError, "unknown backend #{backend.inspect} (known: #{BACKENDS.keys.join(", ")})"
9
+ @registered = {}
12
10
 
13
- const_get(constant).new(**)
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
- result.graded!(data[:reward], credit: data[:credit] || data[:reward]) unless data[:reward].nil?
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
 
@@ -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
- root.glob("**/#{FILENAME}").filter_map { to_record(it) }
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
- root.glob("**/#{FILENAME}").filter_map do |path|
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
- Verification.new(reward:, credit: read_credit(reward), logs: result.output.to_s)
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 read_credit(reward)
132
+ def read_checks
124
133
  path = File.join(task.verifier.logs_dir, "checks.json")
125
- return reward unless environment.exec("test -e #{Shellwords.escape(path)}").success?
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
- checks = begin
131
- JSON.parse(result.output.to_s)
132
- rescue JSON::ParserError => e
133
- raise VerifierError, "#{path} is not JSON: #{e.message[0, 500]}"
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
- grading = checks["grading"]
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Lemans
4
- VERSION = "1.5.0"
4
+ VERSION = "1.5.2"
5
5
  end