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.
- checksums.yaml +4 -4
- data/README.md +30 -10
- data/exe/lemans-remote +94 -29
- 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/clobber.rb
CHANGED
|
@@ -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
|
|
4
|
+
# Deletes stored results based on the provided filters.
|
|
9
5
|
class Clobber
|
|
10
|
-
|
|
6
|
+
include Config::Conversion
|
|
7
|
+
|
|
8
|
+
private attr_reader :store, :tasks, :ttl, :invalid
|
|
11
9
|
|
|
12
|
-
def initialize(
|
|
13
|
-
@
|
|
10
|
+
def initialize(store, tasks: [], ttl: nil, invalid: false)
|
|
11
|
+
@store = store
|
|
14
12
|
@tasks = Array(tasks)
|
|
15
|
-
@
|
|
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
|
-
|
|
24
|
-
|
|
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
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
(
|
|
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
|
|
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
|