lemans 0.2.3 → 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.
Files changed (69) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +30 -10
  3. data/exe/lemans-remote +94 -29
  4. data/lib/lemans/agent.rb +39 -0
  5. data/lib/lemans/agents/miniswen.rb +28 -25
  6. data/lib/lemans/agents/miniswen_installed.rb +13 -9
  7. data/lib/lemans/agents/nop.rb +3 -3
  8. data/lib/lemans/agents/oracle.rb +8 -8
  9. data/lib/lemans/cli/board_reporter.rb +16 -16
  10. data/lib/lemans/cli/progress_reporter.rb +19 -19
  11. data/lib/lemans/cli/report/aggregate.rb +117 -0
  12. data/lib/lemans/cli/report.rb +164 -0
  13. data/lib/lemans/cli.rb +58 -63
  14. data/lib/lemans/clobber.rb +17 -55
  15. data/lib/lemans/config/agent.rb +51 -0
  16. data/lib/lemans/config/conversion.rb +62 -0
  17. data/lib/lemans/config/environment.rb +39 -0
  18. data/lib/lemans/config/image_spec.rb +42 -0
  19. data/lib/lemans/config/network_policy.rb +55 -0
  20. data/lib/lemans/config/revision.rb +42 -0
  21. data/lib/lemans/config/setup.rb +57 -0
  22. data/lib/lemans/config/tree_digest.rb +26 -0
  23. data/lib/lemans/config/verifier.rb +72 -0
  24. data/lib/lemans/config.rb +107 -0
  25. data/lib/lemans/environment.rb +54 -0
  26. data/lib/lemans/environments/daytona/faraday_transfer.rb +172 -0
  27. data/lib/lemans/environments/daytona/sdk_tweaks.rb +1 -1
  28. data/lib/lemans/environments/daytona/shell.rb +1 -1
  29. data/lib/lemans/environments/daytona/snapshot_store.rb +11 -11
  30. data/lib/lemans/environments/daytona.rb +21 -35
  31. data/lib/lemans/result.rb +270 -0
  32. data/lib/lemans/runner/executor.rb +64 -0
  33. data/lib/lemans/runner/task.rb +62 -0
  34. data/lib/lemans/runner.rb +82 -0
  35. data/lib/lemans/store.rb +44 -0
  36. data/lib/lemans/stores/fs.rb +122 -0
  37. data/lib/lemans/task_definition.rb +194 -0
  38. data/lib/lemans/trial/patch.rb +76 -0
  39. data/lib/lemans/trial/setup.rb +66 -0
  40. data/lib/lemans/trial/snapshot.rb +57 -0
  41. data/lib/lemans/trial/verifier.rb +190 -0
  42. data/lib/lemans/trial.rb +113 -147
  43. data/lib/lemans/version.rb +1 -1
  44. data/lib/lemans.rb +3 -2
  45. data/lib/miniswen/trajectory.rb +2 -0
  46. metadata +58 -25
  47. data/lib/lemans/agents/base.rb +0 -30
  48. data/lib/lemans/bench.rb +0 -280
  49. data/lib/lemans/environments/base.rb +0 -55
  50. data/lib/lemans/network_policy.rb +0 -66
  51. data/lib/lemans/patch.rb +0 -70
  52. data/lib/lemans/restore_paths.rb +0 -21
  53. data/lib/lemans/results/aggregate.rb +0 -114
  54. data/lib/lemans/results/cost_source.rb +0 -13
  55. data/lib/lemans/results/outcome.rb +0 -36
  56. data/lib/lemans/results/report.rb +0 -149
  57. data/lib/lemans/results/sorting.rb +0 -24
  58. data/lib/lemans/results/tally.rb +0 -19
  59. data/lib/lemans/results/usage.rb +0 -24
  60. data/lib/lemans/run.rb +0 -152
  61. data/lib/lemans/setup.rb +0 -59
  62. data/lib/lemans/setup_files.rb +0 -36
  63. data/lib/lemans/snapshot.rb +0 -55
  64. data/lib/lemans/task.rb +0 -207
  65. data/lib/lemans/tree_digest.rb +0 -24
  66. data/lib/lemans/units.rb +0 -44
  67. data/lib/lemans/verifier.rb +0 -199
  68. /data/lib/lemans/{verifier → trial/verifier}/assets/eport-lemans.rb +0 -0
  69. /data/lib/lemans/{verifier → trial/verifier}/assets/lemans_minitest_reporter.rb +0 -0
@@ -1,18 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "fileutils"
4
- require "json"
5
- require "pathname"
6
-
7
3
  module Lemans
8
- # Deletes run directories based on the provided filters.
4
+ # Deletes stored results based on the provided filters.
9
5
  class Clobber
10
- TRIAL_DIR = /\A(?<task>.+)__[A-Za-z0-9]{7}\z/
6
+ include Config::Conversion
7
+
8
+ private attr_reader :store, :tasks, :ttl, :invalid
11
9
 
12
- def initialize(runs_dir:, tasks: [], ttl_sec: nil, invalid: false)
13
- @runs_dir = Pathname(runs_dir)
10
+ def initialize(store, tasks: [], ttl: nil, invalid: false)
11
+ @store = store
14
12
  @tasks = Array(tasks)
15
- @ttl_sec = ttl_sec
13
+ @ttl = ttl && seconds!(ttl)
16
14
  @invalid = invalid
17
15
  end
18
16
 
@@ -20,60 +18,24 @@ module Lemans
20
18
  @matches ||= select_matches
21
19
  end
22
20
 
23
- # Deletes everything it can, says what it could not, and returns what it
24
- # actually removed — the caller's "deleted N" must not count survivors.
25
- def call
26
- deleted = matches.select do |entry|
27
- FileUtils.remove_entry(entry.to_s)
28
- true
29
- rescue SystemCallError => e
30
- warn "lemans: could not delete #{entry}: #{e.message}"
31
- false
32
- end
33
- prune_emptied_parents(deleted)
34
- deleted
21
+ def execute!
22
+ matches.select { store.delete(it) }
35
23
  end
36
24
 
37
25
  private
38
26
 
39
- attr_reader :runs_dir, :tasks, :ttl_sec
40
-
41
27
  def select_matches
42
- return [] unless runs_dir.directory?
43
-
44
- runs_dir.glob("**/").map(&:cleanpath).sort.select do |entry|
45
- task = task_name(entry)
46
- next false if task.nil?
28
+ candidates = store.fetch
29
+ candidates += store.unreadable if invalid
47
30
 
48
- (tasks.empty? || tasks.include?(task)) &&
49
- (ttl_sec.nil? || age_sec(entry) > ttl_sec) &&
50
- (!@invalid || invalid?(entry))
31
+ matched = candidates.select do |result|
32
+ (tasks.empty? || tasks.include?(result.task)) &&
33
+ (ttl.nil? || age(result) > ttl) &&
34
+ (!invalid || result.invalid?)
51
35
  end
36
+ matched.sort_by { it.id.to_s }
52
37
  end
53
38
 
54
- def prune_emptied_parents(deleted)
55
- root = runs_dir.cleanpath
56
- deleted.each do |entry|
57
- dir = entry.parent
58
- while dir != root && dir.children.empty?
59
- dir.rmdir
60
- dir = dir.parent
61
- end
62
- rescue SystemCallError
63
- next
64
- end
65
- end
66
-
67
- def task_name(entry) = TRIAL_DIR.match(entry.basename.to_s)&.[](:task)
68
-
69
- def age_sec(entry) = Time.now - entry.mtime
70
-
71
- # A trial that measured nothing; an unreadable result counts — it will
72
- # never be read as anything else.
73
- def invalid?(entry)
74
- JSON.parse(entry.join("result.json").read).dig("outcome", "scored") != true
75
- rescue JSON::ParserError, SystemCallError, IOError
76
- true
77
- end
39
+ def age(result) = result.finished_at ? Time.now.utc - result.finished_at : Float::INFINITY
78
40
  end
79
41
  end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lemans
4
+ class Config
5
+ class Agent # :nodoc:
6
+ class << self
7
+ include Conversion
8
+
9
+ def from_config(data)
10
+ return if data.nil?
11
+
12
+ name = data.fetch("name")
13
+ model = data.fetch("model")
14
+
15
+ conf = new(name, model)
16
+
17
+ conf.step_limit = integer!(data["step_limit"]) if data["step_limit"]
18
+ conf.cost_limit = float!(data["cost_limit"]) if data["cost_limit"]
19
+ conf.timeout = seconds!(data["timeout"]) if data["timeout"]
20
+ conf.exec_timeout = seconds!(data["exec_timeout"]) if data["exec_timeout"]
21
+
22
+ if (network_data = data.dig("environment", "network"))
23
+ conf.environment = Environment.new(network: NetworkPolicy.from_config(network_data))
24
+ end
25
+
26
+ conf
27
+ rescue KeyError => e
28
+ raise ConfigError, "agent.#{e.key} must be provided"
29
+ end
30
+ end
31
+
32
+ attr_accessor :name, :models, :timeout,
33
+ :step_limit, :cost_limit, :exec_timeout,
34
+ :environment
35
+
36
+ def model = models.first
37
+
38
+ Environment = Struct.new(:network, keyword_init: true)
39
+
40
+ def initialize(name, model)
41
+ @name = name
42
+ @models = Array(model)
43
+ @step_limit = 100
44
+ @cost_limit = nil
45
+ @exec_timeout = 300
46
+ @timeout = 30 * 60
47
+ @environment = Environment.new(network: NetworkPolicy.new)
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module Lemans
6
+ class Config
7
+ # Helper methods to parse/convert config-provided values
8
+ module Conversion
9
+ DURATION = /\A(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?\z/
10
+ DURATION_FACTORS = { "ms" => 0.001, "s" => 1, "m" => 60, "h" => 3600, "d" => 86_400 }.freeze
11
+
12
+ SIZE = /\A(\d+(?:\.\d+)?)\s*(MB|GB|TB)?\z/i
13
+ SIZE_FACTORS = { "mb" => 1, "gb" => 1024, "tb" => 1024 * 1024 }.freeze
14
+
15
+ def integer!(value)
16
+ Integer(value)
17
+ rescue ArgumentError, TypeError
18
+ raise ConfigError, "cannot read #{value.inspect} as an integer"
19
+ end
20
+
21
+ def float!(value)
22
+ Float(value)
23
+ rescue ArgumentError, TypeError
24
+ raise ConfigError, "cannot read #{value.inspect} as a number"
25
+ end
26
+
27
+ def seconds!(value)
28
+ if value.is_a?(Numeric)
29
+ raise ConfigError, "duration must be non-negative, got: #{value}" if value.negative?
30
+
31
+ return value
32
+ end
33
+
34
+ match = DURATION.match(value.to_s.strip)
35
+
36
+ raise ConfigError, "cannot read #{value.inspect} as a duration (try 30m, 300s, 1h)" unless match
37
+
38
+ match[1].to_f * DURATION_FACTORS.fetch(match[2] || "s")
39
+ end
40
+
41
+ def megabytes!(value)
42
+ if value.is_a?(Numeric)
43
+ raise ConfigError, "size must be non-negative, got: #{value}" if value.negative?
44
+
45
+ return value.round
46
+ end
47
+
48
+ match = SIZE.match(value.to_s.strip)
49
+
50
+ raise ConfigError, "cannot read #{value.inspect} as a size (try 2GB, 512MB)" unless match
51
+
52
+ (match[1].to_f * SIZE_FACTORS.fetch((match[2] || "mb").downcase)).round
53
+ end
54
+
55
+ def absolute_path!(value)
56
+ raise ConfigError, "#{value.inspect} is not an absolute path" unless value.start_with?("/")
57
+
58
+ value
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lemans
4
+ class Config
5
+ class Environment # :nodoc:
6
+ Resources = Struct.new(:cpus, :memory, :storage, keyword_init: true)
7
+
8
+ class << self
9
+ include Conversion
10
+
11
+ def from_config(data)
12
+ return if data.nil?
13
+
14
+ conf = new
15
+ conf.image = data["image"] if data["image"]
16
+ conf.workdir = absolute_path!(data["workdir"]) if data["workdir"]
17
+ conf.build_timeout = seconds!(data["build_timeout"]) if data["build_timeout"]
18
+ conf.network = NetworkPolicy.from_config(data["network"]) if data["network"]
19
+
20
+ conf.resources.cpus = integer!(data.dig("resources", "cpus")) if data.dig("resources", "cpus")
21
+ conf.resources.memory = megabytes!(data.dig("resources", "memory")) if data.dig("resources", "memory")
22
+ conf.resources.storage = megabytes!(data.dig("resources", "storage")) if data.dig("resources", "storage")
23
+
24
+ conf
25
+ end
26
+ end
27
+
28
+ attr_accessor :image, :workdir, :resources, :build_timeout, :network
29
+
30
+ def initialize
31
+ @image = nil
32
+ @workdir = "/app"
33
+ @resources = Resources.new(cpus: 2, memory: 2048, storage: 5120)
34
+ @build_timeout = 10 * 60
35
+ @network = NetworkPolicy.new
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "pathname"
5
+
6
+ module Lemans
7
+ class Config
8
+ # An image, either already published or built from a task's Dockerfile.
9
+ # Built images are named by content digest, so reuse is only ever of the identical thing.
10
+ class ImageSpec
11
+ attr_reader :reference, :dockerfile_path, :slug, :digest
12
+
13
+ def self.registry(reference) = new(reference:)
14
+
15
+ def self.dockerfile(path, slug:)
16
+ path = Pathname(path)
17
+ raise ConfigError, "no Dockerfile at #{path}" unless path.file?
18
+
19
+ new(dockerfile_path: path, slug:)
20
+ end
21
+
22
+ def initialize(reference: nil, dockerfile_path: nil, slug: nil)
23
+ @reference = reference
24
+ @dockerfile_path = dockerfile_path
25
+ @slug = slug
26
+
27
+ # Hashing the reference gives backends one answer to "is this the same image" either way.
28
+ @digest = built? ? TreeDigest.call(context_dir) : Digest::SHA256.hexdigest(reference.to_s)
29
+ end
30
+
31
+ def built? = !dockerfile_path.nil?
32
+
33
+ def context_dir = dockerfile_path&.dirname
34
+
35
+ def name
36
+ built? ? "lemans-#{digest[0, 32]}" : reference
37
+ end
38
+
39
+ def to_s = name
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ipaddr"
4
+
5
+ module Lemans
6
+ class Config
7
+ class NetworkPolicy # :nodoc:
8
+ MODES = %w[none allowlist public].freeze
9
+
10
+ MODES.each do |name|
11
+ define_method(:"#{name}?") { mode == name.to_s }
12
+ end
13
+
14
+ class << self
15
+ def from_config(data)
16
+ return if data.nil?
17
+
18
+ mode = (data["mode"] || "public").to_s
19
+ raise ConfigError, "#{mode.inspect} is not a network mode (#{MODES.join(", ")})" unless MODES.include?(mode)
20
+
21
+ hosts = data["hosts"]
22
+ raise ConfigError, "network.hosts is only meaningful with mode: allowlist" if mode != "allowlist" && hosts
23
+ raise ConfigError, "network.hosts must be provided with mode: allowlist" if mode == "allowlist" && Array(hosts).empty?
24
+
25
+ new(mode, hosts)
26
+ end
27
+ end
28
+
29
+ attr_reader :mode, :hosts
30
+
31
+ def initialize(mode = "public", hosts = nil)
32
+ @mode = mode.to_s
33
+ @hosts = hosts
34
+ end
35
+
36
+ # Backends allowlist domains and IP ranges through separate APIs.
37
+ def domains = partitioned_hosts.last
38
+
39
+ def ip_targets = partitioned_hosts.first
40
+
41
+ private
42
+
43
+ def partitioned_hosts
44
+ @partitioned_hosts ||= Array(hosts).partition { ip_target?(it) }
45
+ end
46
+
47
+ def ip_target?(entry)
48
+ IPAddr.new(entry)
49
+ true
50
+ rescue IPAddr::Error
51
+ false
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Lemans
6
+ class Config
7
+ # Which revision of a bench produced a score.
8
+ # Obtain either from git or from ENV["COMMIT_SHA"] (lemans-remote convention)
9
+ class Revision
10
+ class << self
11
+ def detect(dir)
12
+ commit, dirty = ENV["COMMIT_SHA"]&.split("~")
13
+ return new(commit, dirty == "!") if commit
14
+
15
+ commit = git("rev-parse", "HEAD", dir:)
16
+ return new if commit.nil?
17
+
18
+ status = git("status", "--porcelain", dir:)
19
+ new(commit, status.nil? ? nil : !status.empty?)
20
+ end
21
+
22
+ private
23
+
24
+ def git(*, dir:)
25
+ output, status = Open3.capture2e("git", "-C", dir.to_s, *)
26
+ status.success? ? output.strip : nil
27
+ rescue SystemCallError
28
+ nil
29
+ end
30
+ end
31
+
32
+ attr_reader :commit, :dirty
33
+
34
+ def initialize(commit = nil, dirty = nil)
35
+ @commit = commit
36
+ @dirty = dirty
37
+ end
38
+
39
+ def as_json(**) = { commit:, dirty: }
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lemans
4
+ class Config
5
+ # One phase's preparation: the files uploaded into the sandbox and the
6
+ # commands run against them. A bare list is shorthand for commands only.
7
+ class Setup
8
+ class << self
9
+ def from_config(data, root:)
10
+ return if data.nil?
11
+
12
+ conf = new
13
+ case data
14
+ when Hash
15
+ conf.commands = Array(data["commands"])
16
+ conf.files = Array(data["files"]).map { file!(it, root:) }
17
+ else
18
+ conf.commands = Array(data)
19
+ end
20
+
21
+ conf
22
+ end
23
+
24
+ private
25
+
26
+ # Files are confined to the directory that declared them and must
27
+ # exist at load time.
28
+ def file!(path, root:)
29
+ raise ConfigError, "setup file #{path.inspect} must be relative to #{root}" if path.start_with?("/")
30
+
31
+ relative = Pathname(path).cleanpath
32
+ raise ConfigError, "setup file #{path.inspect} must name a file inside #{root}" if relative.each_filename.include?("..") || relative.to_s == "."
33
+ raise ConfigError, "setup file #{path} is not a file" unless root.join(relative).file?
34
+
35
+ [root.join(relative), relative.to_s]
36
+ end
37
+ end
38
+
39
+ # Files are [local absolute, remote relative] pairs; commands run in order.
40
+ attr_accessor :files, :commands
41
+
42
+ def initialize
43
+ @files = []
44
+ @commands = []
45
+ end
46
+
47
+ def merge(other)
48
+ dup.tap do |merged|
49
+ merged.files = files + other.files
50
+ merged.commands = commands + other.commands
51
+ end
52
+ end
53
+
54
+ def empty? = files.empty? && commands.empty?
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Lemans
6
+ class Config
7
+ # A directory's contents reduced to one number. Paths hash alongside
8
+ # contents, and dotfiles are included — glob skips them by default.
9
+ module TreeDigest
10
+ def self.call(dir)
11
+ dir = Pathname(dir)
12
+ sha = Digest::SHA256.new
13
+ dir.glob("**/*", File::FNM_DOTMATCH).sort.each do |entry|
14
+ next unless entry.file?
15
+
16
+ path = entry.relative_path_from(dir).to_s
17
+ contents = entry.binread
18
+ sha << [path.bytesize, contents.bytesize].pack("Q>Q>")
19
+ sha << path
20
+ sha << contents
21
+ end
22
+ sha.hexdigest
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module Lemans
6
+ class Config
7
+ class Verifier # :nodoc:
8
+ DEFAULT_COMMAND = "if [ -x /tests/verify ]; then exec /tests/verify; " \
9
+ "elif [ -f /tests/verification_test.rb ]; then exec ruby -report-lemans /tests/verification_test.rb; " \
10
+ "else exec bash /tests/test.sh; fi"
11
+
12
+ VERIFICATION_DIR = "verification"
13
+
14
+ class << self
15
+ include Conversion
16
+
17
+ def from_config(data, root: Pathname("./"))
18
+ return if data.nil?
19
+
20
+ conf = new
21
+ conf.timeout = seconds!(data["timeout"]) if data["timeout"]
22
+ conf.setup = Setup.from_config(data["setup"], root:) if data["setup"]
23
+ conf.command = data["command"] if data["command"]
24
+ conf.preverify = data["preverify"] if data["preverify"]
25
+ conf.restore_paths = restore_paths!(data["restore"]) if data["restore"]
26
+ conf.logs_dir = absolute_path!(data["logs_dir"]) if data["logs_dir"]
27
+
28
+ conf
29
+ end
30
+
31
+ # The graded surfaces restored from the pre-agent snapshot before the
32
+ # command runs; a task may override the list in its frontmatter.
33
+ def restore_paths!(declared)
34
+ Array(declared).map do |path|
35
+ raise ConfigError, "restore path must be workdir-relative, got #{path.inspect}" if path.start_with?("/")
36
+ raise ConfigError, "restore path must not escape the workdir: #{path.inspect}" if path.split("/").include?("..")
37
+ raise ConfigError, "restore path must name something inside the workdir, got #{path.inspect}" if Pathname(path).cleanpath.to_s == "."
38
+
39
+ path
40
+ end
41
+ end
42
+ end
43
+
44
+ attr_accessor :root, :timeout, :setup, :command, :preverify, :restore_paths, :logs_dir
45
+
46
+ def initialize
47
+ @root = Pathname("./")
48
+ @timeout = 10 * 60
49
+ @setup = Setup.new
50
+ @command = DEFAULT_COMMAND
51
+ @preverify = nil
52
+ @restore_paths = []
53
+ @logs_dir = "/logs/verifier"
54
+ end
55
+
56
+ def reward_path = "#{logs_dir.chomp("/")}/reward.txt"
57
+
58
+ # [absolute, remote-relative] pairs. Shared verification files grade
59
+ # every trial, so they ship alongside each task's own tests.
60
+ def files
61
+ @files ||= begin
62
+ dir = root.join(VERIFICATION_DIR)
63
+ if dir.directory?
64
+ dir.glob("**/*", File::FNM_DOTMATCH).select(&:file?).map { [it, it.relative_path_from(dir).to_s] }
65
+ else
66
+ []
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "yaml"
5
+ require "pathname"
6
+
7
+ module Lemans
8
+ # Benchmark configuration and task definitions container.
9
+ class Config
10
+ attr_reader :root, :config_path, :tasks_dir,
11
+ :tasks, :version,
12
+ :backend, :concurrency, :attempts
13
+
14
+ # Nested configs
15
+ attr_reader :environment, :agent, :verifier, :setup
16
+
17
+ class << self
18
+ def load_file(path)
19
+ raise ConfigError, "bench directory not found: #{path}" unless File.directory?(path)
20
+
21
+ config_name = %w[bench.yaml bench.yml].find { File.file?(File.join(path, it)) }
22
+
23
+ raise ConfigError, "bench.yml not found" unless config_name
24
+
25
+ config_path = Pathname(File.join(path, config_name))
26
+
27
+ contents = YAML.safe_load_file(config_path.to_s, aliases: true) || {}
28
+ raise ConfigError, "#{path}: #{config_name} must be a mapping of sections" unless contents.is_a?(Hash)
29
+
30
+ root = Pathname(path)
31
+
32
+ sections = {}
33
+ sections[:version] = contents["version"]
34
+ sections[:tasks_dir] = contents["tasks"]
35
+ sections[:setup] = Setup.from_config(contents["setup"], root:)
36
+ sections[:agent] = Agent.from_config(contents["agent"])
37
+ sections[:environment] = Environment.from_config(contents["environment"])
38
+ sections[:verifier] = Verifier.from_config(contents["verifier"], root:)
39
+ sections.compact!
40
+
41
+ # Older benches declared environment-phase commands under `environment.setup`.
42
+ if (commands = contents.dig("environment", "setup"))
43
+ sections[:setup] ||= Setup.new
44
+ sections[:setup].commands = Array(commands) + sections[:setup].commands
45
+ end
46
+
47
+ new(root, config_path:, **sections)
48
+ rescue Psych::Exception => e
49
+ raise ConfigError, "#{path}: #{e.message}"
50
+ end
51
+ end
52
+
53
+ def initialize(root = Pathname("./"), config_path: Pathname("./bench.yml"), version: nil, tasks_dir: "tasks",
54
+ setup: nil, agent: Agent.new("miniswen", "openrouter/z-ai/glm-5.2"),
55
+ environment: Environment.new, verifier: Verifier.new)
56
+ @root = root
57
+ @config_path = config_path
58
+ @version = version
59
+ @tasks_dir = root.join(tasks_dir)
60
+ @setup = setup || Setup.new
61
+ @agent = agent
62
+ @environment = environment
63
+ @verifier = verifier
64
+ @verifier.root = root
65
+ @concurrency = 4
66
+ @attempts = 1
67
+ @backend = "daytona"
68
+ @tasks = parse_tasks
69
+ end
70
+
71
+ def load_options(agent: nil, model: nil, attempts: nil, concurrency: nil, backend: nil, **)
72
+ @agent.name = agent if agent
73
+ @agent.models = Array(model) if model
74
+ @attempts = attempts if attempts
75
+ @concurrency = concurrency if concurrency
76
+ @backend = backend if backend
77
+ end
78
+
79
+ def agent_name = agent.name
80
+
81
+ def models = agent.models
82
+
83
+ # Resolved once: an hours-long run reports the bench it started from, not
84
+ # later tree drift.
85
+ def revision
86
+ @revision ||= Revision.detect(root)
87
+ end
88
+
89
+ # Digest captures the state of the config and verification files. Used for resuming runs mostly
90
+ def digest
91
+ @digest ||= begin
92
+ sha = Digest::SHA256.new
93
+ sha << TreeDigest.call(root.join(Verifier::VERIFICATION_DIR))
94
+ sha << Digest::SHA256.file(config_path.to_s).hexdigest if config_path.file?
95
+ sha.hexdigest[0, 16]
96
+ end
97
+ end
98
+
99
+ private
100
+
101
+ def parse_tasks
102
+ return [] unless tasks_dir.directory?
103
+
104
+ tasks_dir.children.select(&:directory?).sort.map { TaskDefinition.load_from_directory(self, it) }
105
+ end
106
+ end
107
+ end