lemans 1.0.0 → 1.2.0
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 +11 -0
- data/README.md +15 -3
- data/exe/lemans-remote +105 -12
- data/lib/lemans/agents/oracle.rb +13 -7
- data/lib/lemans/cli/templates/bench/README.md +7 -2
- data/lib/lemans/cli/templates/bench/bench.yml +3 -1
- data/lib/lemans/cli/templates/bench/tasks/example-task/instruction.md +20 -9
- data/lib/lemans/cli/templates/bench/tasks/example-task/solution.1.patch +17 -0
- data/lib/lemans/cli/templates/bench/tasks/example-task/solution.2.patch +17 -0
- data/lib/lemans/cli/templates/bench/tasks/example-task/verification_test.1.rb +31 -0
- data/lib/lemans/cli/templates/bench/tasks/example-task/verification_test.rb +1 -1
- data/lib/lemans/config/agent.rb +12 -0
- data/lib/lemans/config/environment.rb +28 -2
- data/lib/lemans/config/network_policy.rb +2 -0
- data/lib/lemans/config/setup.rb +6 -1
- data/lib/lemans/config/verifier.rb +18 -7
- data/lib/lemans/config.rb +44 -9
- data/lib/lemans/environments/docker.rb +1 -1
- data/lib/lemans/ext/deep_merge.rb +13 -0
- data/lib/lemans/result.rb +41 -1
- data/lib/lemans/runner.rb +1 -1
- data/lib/lemans/stores/fs.rb +1 -1
- data/lib/lemans/task_definition.rb +158 -10
- data/lib/lemans/trial/patch.rb +46 -8
- data/lib/lemans/trial/verifier/assets/lemans_minitest_reporter.rb +28 -3
- data/lib/lemans/trial.rb +83 -33
- data/lib/lemans/version.rb +1 -1
- data/lib/miniswen/agent.rb +12 -6
- data/lib/miniswen/cli/reporter.rb +104 -0
- data/lib/miniswen/cli.rb +36 -82
- data/lib/miniswen/environment/docker.rb +69 -0
- data/lib/miniswen/version.rb +1 -1
- metadata +7 -2
- data/lib/lemans/cli/templates/bench/tasks/example-task/solution.patch +0 -7
|
@@ -24,6 +24,7 @@ module Lemans
|
|
|
24
24
|
conf.preverify = data["preverify"] if data["preverify"]
|
|
25
25
|
conf.restore_paths = restore_paths!(data["restore"]) if data["restore"]
|
|
26
26
|
conf.logs_dir = absolute_path!(data["logs_dir"]) if data["logs_dir"]
|
|
27
|
+
conf.verification = root.join(data["verification"]) if data["verification"]
|
|
27
28
|
|
|
28
29
|
conf
|
|
29
30
|
end
|
|
@@ -41,31 +42,41 @@ module Lemans
|
|
|
41
42
|
end
|
|
42
43
|
end
|
|
43
44
|
|
|
44
|
-
attr_accessor :
|
|
45
|
+
attr_accessor :timeout, :setup, :command, :preverify, :restore_paths, :logs_dir, :verification
|
|
45
46
|
|
|
46
47
|
def initialize
|
|
47
|
-
@root = Pathname("./")
|
|
48
48
|
@timeout = 10 * 60
|
|
49
49
|
@setup = Setup.new
|
|
50
50
|
@command = DEFAULT_COMMAND
|
|
51
51
|
@preverify = nil
|
|
52
52
|
@restore_paths = []
|
|
53
53
|
@logs_dir = "/logs/verifier"
|
|
54
|
+
@verification = nil
|
|
54
55
|
end
|
|
55
56
|
|
|
56
57
|
def reward_path = "#{logs_dir.chomp("/")}/reward.txt"
|
|
57
58
|
|
|
59
|
+
def to_h
|
|
60
|
+
{
|
|
61
|
+
"timeout" => timeout,
|
|
62
|
+
"setup" => setup.to_h,
|
|
63
|
+
"command" => command,
|
|
64
|
+
"preverify" => preverify,
|
|
65
|
+
"restore" => restore_paths,
|
|
66
|
+
"logs_dir" => logs_dir,
|
|
67
|
+
"verification" => verification&.to_s
|
|
68
|
+
}.compact
|
|
69
|
+
end
|
|
70
|
+
|
|
58
71
|
# [absolute, remote-relative] pairs. Shared verification files grade
|
|
59
72
|
# every trial, so they ship alongside each task's own tests.
|
|
60
73
|
def files
|
|
61
|
-
@files ||=
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
dir.glob("**/*", File::FNM_DOTMATCH).select(&:file?).map { [ it, it.relative_path_from(dir).to_s ] }
|
|
74
|
+
@files ||=
|
|
75
|
+
if verification&.directory?
|
|
76
|
+
verification.glob("**/*", File::FNM_DOTMATCH).select(&:file?).map { [ it, it.relative_path_from(verification).to_s ] }
|
|
65
77
|
else
|
|
66
78
|
[]
|
|
67
79
|
end
|
|
68
|
-
end
|
|
69
80
|
end
|
|
70
81
|
end
|
|
71
82
|
end
|
data/lib/lemans/config.rb
CHANGED
|
@@ -7,18 +7,24 @@ require "pathname"
|
|
|
7
7
|
module Lemans
|
|
8
8
|
# Benchmark configuration and task definitions container.
|
|
9
9
|
class Config
|
|
10
|
-
attr_reader :root, :config_path, :tasks_dir,
|
|
11
|
-
:tasks, :version,
|
|
10
|
+
attr_reader :root, :config_path, :parent, :tasks_dir, :version,
|
|
12
11
|
:backend, :concurrency, :attempts
|
|
13
12
|
|
|
14
13
|
# Nested configs
|
|
15
14
|
attr_reader :environment, :agent, :verifier, :setup
|
|
16
15
|
|
|
16
|
+
using Ext::DeepMerge
|
|
17
|
+
|
|
18
|
+
FILENAMES = %w[bench.yaml bench.yml].freeze
|
|
19
|
+
|
|
17
20
|
class << self
|
|
18
|
-
|
|
21
|
+
# A task directory's bench.yml is loaded with the bench as its `parent`:
|
|
22
|
+
# inherit_from is implied.
|
|
23
|
+
def load_file(path, parent: nil)
|
|
24
|
+
path = File.dirname(path) if File.file?(path)
|
|
19
25
|
raise ConfigError, "bench directory not found: #{path}" unless File.directory?(path)
|
|
20
26
|
|
|
21
|
-
config_name =
|
|
27
|
+
config_name = FILENAMES.find { File.file?(File.join(path, it)) }
|
|
22
28
|
|
|
23
29
|
raise ConfigError, "bench.yml not found" unless config_name
|
|
24
30
|
|
|
@@ -29,6 +35,17 @@ module Lemans
|
|
|
29
35
|
|
|
30
36
|
root = Pathname(path)
|
|
31
37
|
|
|
38
|
+
parent = load_file(root.join(contents["inherit_from"])) if contents["inherit_from"]
|
|
39
|
+
|
|
40
|
+
if parent
|
|
41
|
+
# Local conventions win over the inherited config (unless declared, even as ~)
|
|
42
|
+
environment = (contents["environment"] ||= {})
|
|
43
|
+
environment["dockerfile"] = root.join("environment/Dockerfile").to_s if root.join("environment/Dockerfile").file? && !environment.key?("dockerfile")
|
|
44
|
+
verifier = (contents["verifier"] ||= {})
|
|
45
|
+
verifier["verification"] = root.join(Verifier::VERIFICATION_DIR).to_s if root.join(Verifier::VERIFICATION_DIR).directory? && !verifier.key?("verification")
|
|
46
|
+
contents = parent.to_h.deep_merge(contents)
|
|
47
|
+
end
|
|
48
|
+
|
|
32
49
|
sections = {}
|
|
33
50
|
sections[:version] = contents["version"]
|
|
34
51
|
sections[:tasks_dir] = contents["tasks"]
|
|
@@ -44,17 +61,18 @@ module Lemans
|
|
|
44
61
|
sections[:setup].commands = Array(commands) + sections[:setup].commands
|
|
45
62
|
end
|
|
46
63
|
|
|
47
|
-
new(root, config_path:, **sections)
|
|
64
|
+
new(root, config_path:, parent:, **sections)
|
|
48
65
|
rescue Psych::Exception => e
|
|
49
66
|
raise ConfigError, "#{path}: #{e.message}"
|
|
50
67
|
end
|
|
51
68
|
end
|
|
52
69
|
|
|
53
|
-
def initialize(root = Pathname("./"), config_path: Pathname("./bench.yml"), version: nil, tasks_dir: "tasks",
|
|
70
|
+
def initialize(root = Pathname("./"), config_path: Pathname("./bench.yml"), parent: nil, version: nil, tasks_dir: "tasks",
|
|
54
71
|
setup: nil, agent: Agent.new("miniswen", "openrouter/z-ai/glm-5.2"),
|
|
55
72
|
environment: Environment.new, verifier: Verifier.new)
|
|
56
73
|
@root = root
|
|
57
74
|
@config_path = config_path
|
|
75
|
+
@parent = parent
|
|
58
76
|
@version = version
|
|
59
77
|
@tasks_dir = root.join(tasks_dir)
|
|
60
78
|
@setup = setup || Setup.new
|
|
@@ -62,26 +80,41 @@ module Lemans
|
|
|
62
80
|
@environment = environment
|
|
63
81
|
@environment.dockerfile = root.join(@environment.dockerfile) if @environment.dockerfile
|
|
64
82
|
@environment.dockerfile ||= default_dockerfile unless @environment.image
|
|
83
|
+
@environment.profiles.each_value { it.dockerfile = root.join(it.dockerfile) if it.dockerfile }
|
|
65
84
|
@verifier = verifier
|
|
66
|
-
@verifier.
|
|
85
|
+
@verifier.verification ||= root.join(Verifier::VERIFICATION_DIR)
|
|
67
86
|
@concurrency = 4
|
|
68
87
|
@attempts = 1
|
|
69
88
|
@backend = @environment.backend
|
|
70
|
-
@tasks = parse_tasks
|
|
71
89
|
end
|
|
72
90
|
|
|
91
|
+
def tasks = @tasks ||= parse_tasks
|
|
92
|
+
|
|
73
93
|
def load_options(agent: nil, model: nil, attempts: nil, concurrency: nil, backend: nil, **)
|
|
74
94
|
@agent.name = agent if agent
|
|
75
95
|
@agent.models = Array(model) if model
|
|
76
96
|
@attempts = attempts if attempts
|
|
77
97
|
@concurrency = concurrency if concurrency
|
|
78
98
|
@backend = environment.backend = backend if backend
|
|
99
|
+
tasks.each { it.config.load_options(agent:, model:, attempts:, concurrency:, backend:) unless it.config.equal?(self) }
|
|
79
100
|
end
|
|
80
101
|
|
|
81
102
|
def agent_name = agent.name
|
|
82
103
|
|
|
83
104
|
def models = agent.models
|
|
84
105
|
|
|
106
|
+
# The bench.yml this config reads as, paths resolved: what a bench
|
|
107
|
+
# inheriting from it merges over. Tasks are a bench's own.
|
|
108
|
+
def to_h
|
|
109
|
+
{
|
|
110
|
+
"version" => version,
|
|
111
|
+
"setup" => setup.to_h,
|
|
112
|
+
"agent" => agent.to_h,
|
|
113
|
+
"environment" => environment.to_h,
|
|
114
|
+
"verifier" => verifier.to_h
|
|
115
|
+
}.compact
|
|
116
|
+
end
|
|
117
|
+
|
|
85
118
|
# Resolved once: an hours-long run reports the bench it started from, not
|
|
86
119
|
# later tree drift.
|
|
87
120
|
def revision
|
|
@@ -92,9 +125,11 @@ module Lemans
|
|
|
92
125
|
def digest
|
|
93
126
|
@digest ||= begin
|
|
94
127
|
sha = Digest::SHA256.new
|
|
95
|
-
sha << TreeDigest.call(
|
|
128
|
+
sha << TreeDigest.call(verifier.verification)
|
|
96
129
|
sha << TreeDigest.call(environment.dockerfile.dirname) if environment.dockerfile
|
|
130
|
+
environment.profiles.each_value { sha << TreeDigest.call(it.dockerfile.dirname) if it.dockerfile }
|
|
97
131
|
sha << Digest::SHA256.file(config_path.to_s).hexdigest if config_path.file?
|
|
132
|
+
sha << parent.digest if parent
|
|
98
133
|
sha.hexdigest[0, 16]
|
|
99
134
|
end
|
|
100
135
|
end
|
|
@@ -152,7 +152,7 @@ module Lemans
|
|
|
152
152
|
end
|
|
153
153
|
|
|
154
154
|
status = wait.value
|
|
155
|
-
[ timed_out ? 124 : (status.exitstatus || 1), output.scrub ]
|
|
155
|
+
[ timed_out ? 124 : (status.exitstatus || 1), output.force_encoding(Encoding::UTF_8).scrub ]
|
|
156
156
|
end
|
|
157
157
|
rescue Errno::ENOENT
|
|
158
158
|
raise ConfigError, "docker: CLI not found — install Docker or pick another backend"
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lemans
|
|
4
|
+
module Ext
|
|
5
|
+
module DeepMerge
|
|
6
|
+
refine Hash do
|
|
7
|
+
def deep_merge(other)
|
|
8
|
+
merge(other) { |_, base, over| base.is_a?(Hash) && over.is_a?(Hash) ? base.deep_merge(over) : over }
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
data/lib/lemans/result.rb
CHANGED
|
@@ -59,6 +59,18 @@ module Lemans
|
|
|
59
59
|
:cost_usd, :cost_source
|
|
60
60
|
) do
|
|
61
61
|
def as_json(**) = to_h.merge(cost_source: cost_source&.to_h).compact
|
|
62
|
+
|
|
63
|
+
# A multistep trial's totals; an unknown step cost makes the sum unknown.
|
|
64
|
+
def +(other)
|
|
65
|
+
self.class.new(
|
|
66
|
+
input_tokens: input_tokens + other.input_tokens,
|
|
67
|
+
output_tokens: output_tokens + other.output_tokens,
|
|
68
|
+
cached_tokens: cached_tokens + other.cached_tokens,
|
|
69
|
+
steps: steps + other.steps,
|
|
70
|
+
cost_usd: cost_usd && other.cost_usd && cost_usd + other.cost_usd,
|
|
71
|
+
cost_source: other.cost_source || cost_source
|
|
72
|
+
)
|
|
73
|
+
end
|
|
62
74
|
end
|
|
63
75
|
|
|
64
76
|
def Usage.zero
|
|
@@ -95,6 +107,8 @@ module Lemans
|
|
|
95
107
|
@finished_at = time || Time.now.utc
|
|
96
108
|
end
|
|
97
109
|
|
|
110
|
+
def duration = finished_at && (finished_at - started_at).round(1)
|
|
111
|
+
|
|
98
112
|
def as_json(**)
|
|
99
113
|
{
|
|
100
114
|
name:,
|
|
@@ -117,13 +131,23 @@ module Lemans
|
|
|
117
131
|
def as_json(**) = to_h
|
|
118
132
|
end
|
|
119
133
|
|
|
134
|
+
Step = Data.define(:outcome, :usage, :duration) do
|
|
135
|
+
def as_json(**) = { outcome: outcome.as_json, usage: usage&.as_json, duration: }.compact
|
|
136
|
+
|
|
137
|
+
def self.from_json(data)
|
|
138
|
+
new(outcome: Outcome.from_json(data[:outcome]),
|
|
139
|
+
usage: data[:usage] && Usage.from_json(data[:usage]),
|
|
140
|
+
duration: data[:duration])
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
|
|
120
144
|
# attributes that must be initialized/specified during construction
|
|
121
145
|
attr_reader :id, :task, :agent, :model, :index,
|
|
122
146
|
:profile_digest, :task_digest, :revision
|
|
123
147
|
|
|
124
148
|
attr_accessor :tags, :metadata
|
|
125
149
|
|
|
126
|
-
attr_reader :phases
|
|
150
|
+
attr_reader :phases, :steps
|
|
127
151
|
|
|
128
152
|
# outcome-related attributes (we use setter-like methods, not accessors)
|
|
129
153
|
attr_reader :reward, :outcome, :usage
|
|
@@ -141,6 +165,7 @@ module Lemans
|
|
|
141
165
|
@tags = []
|
|
142
166
|
@metadata = {}
|
|
143
167
|
@phases = []
|
|
168
|
+
@steps = nil
|
|
144
169
|
|
|
145
170
|
@id = id || "#{task}__#{SecureRandom.alphanumeric(7)}"
|
|
146
171
|
@outcome = Outcome.new(:pending)
|
|
@@ -184,6 +209,14 @@ module Lemans
|
|
|
184
209
|
self
|
|
185
210
|
end
|
|
186
211
|
|
|
212
|
+
def step_completed!(outcome, usage = nil, duration: nil)
|
|
213
|
+
outcome = outcome.is_a?(Outcome) ? outcome : Outcome.new(outcome)
|
|
214
|
+
@steps ||= []
|
|
215
|
+
steps << Step.new(outcome:, usage:, duration:)
|
|
216
|
+
# aggregate right away (so we don't lose data on failure)
|
|
217
|
+
completed!(outcome, aggregate_usage)
|
|
218
|
+
end
|
|
219
|
+
|
|
187
220
|
def graded!(reward)
|
|
188
221
|
@reward = reward
|
|
189
222
|
self
|
|
@@ -199,12 +232,15 @@ module Lemans
|
|
|
199
232
|
self
|
|
200
233
|
end
|
|
201
234
|
|
|
235
|
+
private def aggregate_usage = steps.filter_map(&:usage).reduce(:+)
|
|
236
|
+
|
|
202
237
|
def as_json(**)
|
|
203
238
|
{
|
|
204
239
|
trial: id, task:, agent:, model:, index:,
|
|
205
240
|
profile_digest:, task_digest:, revision: revision&.as_json,
|
|
206
241
|
lemans_version: VERSION,
|
|
207
242
|
tags:, metadata:, phases: phases.map(&:as_json),
|
|
243
|
+
steps: steps&.map(&:as_json),
|
|
208
244
|
reward:, outcome: outcome.as_json, usage: usage&.as_json, duration:,
|
|
209
245
|
started_at: started_at&.iso8601,
|
|
210
246
|
finished_at: finished_at&.iso8601
|
|
@@ -222,6 +258,10 @@ module Lemans
|
|
|
222
258
|
result.tags = data[:tags] || []
|
|
223
259
|
result.metadata = data[:metadata] || {}
|
|
224
260
|
phases_from(data).each { result.phases << it }
|
|
261
|
+
# Steps first: the stored outcome/usage below override the aggregates.
|
|
262
|
+
data[:steps]&.map { Step.from_json(it) }&.each do |step|
|
|
263
|
+
result.step_completed!(step.outcome, step.usage, duration: step.duration)
|
|
264
|
+
end
|
|
225
265
|
if data[:outcome]
|
|
226
266
|
result.completed!(
|
|
227
267
|
Outcome.from_json(data[:outcome]),
|
data/lib/lemans/runner.rb
CHANGED
|
@@ -69,7 +69,7 @@ module Lemans
|
|
|
69
69
|
run.task == task.name &&
|
|
70
70
|
run.model == (model || config.agent.model) &&
|
|
71
71
|
run.agent == config.agent_name &&
|
|
72
|
-
run.profile_digest == config.digest &&
|
|
72
|
+
run.profile_digest == task.config.digest &&
|
|
73
73
|
run.task_digest == task.digest &&
|
|
74
74
|
run.scored?
|
|
75
75
|
end
|
data/lib/lemans/stores/fs.rb
CHANGED
|
@@ -128,7 +128,7 @@ module Lemans
|
|
|
128
128
|
|
|
129
129
|
# result.json is stored at <root>/<model-short>/<result-id>
|
|
130
130
|
def result_dir(result)
|
|
131
|
-
root.join((result.model || result.agent).to_s.split("/").last, result.id)
|
|
131
|
+
root.join((result.model || result.agent).to_s.split("/").last.tr("#", "-"), result.id)
|
|
132
132
|
end
|
|
133
133
|
end
|
|
134
134
|
end
|
|
@@ -19,17 +19,32 @@ module Lemans
|
|
|
19
19
|
FLAT_SEED = "environment.patch"
|
|
20
20
|
|
|
21
21
|
FRONTMATTER = /\A---\n(.*?)\n---\n/m
|
|
22
|
+
STEP_SEPARATOR = /^---[ \t]*\n/
|
|
23
|
+
|
|
24
|
+
STEP_TESTS = { "tests.%d" => :dir, "verification_test.%d.rb" => FLAT_TEST, "verify.%d" => "verify" }.freeze
|
|
25
|
+
# The same step files, kept inside the shared tests/ directory
|
|
26
|
+
SHARED_STEP_TESTS = { "verification_test.%d.rb" => FLAT_TEST, "verify.%d" => "verify" }.freeze
|
|
27
|
+
# What the final step alone runs (see Config::Verifier::DEFAULT_COMMAND): never shipped to an earlier step
|
|
28
|
+
FINAL_STEP_TESTS = [ FLAT_TEST, "verify", "test.sh" ].freeze
|
|
29
|
+
STEP_SOLUTIONS = { "solution.%d" => :dir, "solution.%d.patch" => FLAT_SOLUTION,
|
|
30
|
+
"solve.%d" => "solve", "solve.%d.sh" => "solve.sh" }.freeze
|
|
31
|
+
|
|
32
|
+
STEP_FILE = /\A(?:tests\.(?<test>\d+)|verification_test\.(?<test>\d+)\.rb|verify\.(?<test>\d+)|
|
|
33
|
+
solution\.(?<solution>\d+)(?:\.patch)?|solve\.(?<solution>\d+)(?:\.sh)?)\z/x
|
|
22
34
|
|
|
23
35
|
class << self
|
|
24
36
|
def load_from_directory(config, dir)
|
|
25
37
|
dir = Pathname(dir)
|
|
26
38
|
data = frontmatter(dir)
|
|
39
|
+
config = Config.load_file(dir, parent: config) if Config::FILENAMES.any? { dir.join(it).file? }
|
|
27
40
|
|
|
28
41
|
task = new(config, data["name"] || dir.basename.to_s, dir:)
|
|
29
42
|
task.description = data["description"].to_s if data["description"]
|
|
30
43
|
task.difficulty = data["difficulty"].to_sym if data["difficulty"]
|
|
31
44
|
task.tags = Array(data["tags"]).map(&:to_s) if data["tags"]
|
|
32
45
|
task.metadata = data["metadata"] if data["metadata"]
|
|
46
|
+
task.environment_profile = data["environment"] if data["environment"]
|
|
47
|
+
task.multistep = data["multistep"] if data.key?("multistep")
|
|
33
48
|
|
|
34
49
|
declared_setup = Config::Setup.from_config(data["setup"], root: dir)
|
|
35
50
|
refuse_config_collisions!(task, declared_setup, config.setup)
|
|
@@ -79,8 +94,21 @@ module Lemans
|
|
|
79
94
|
raise ConfigError, "#{task.dir}: a task may only override verifier.setup, not verifier.#{extras.first}"
|
|
80
95
|
end
|
|
81
96
|
|
|
82
|
-
|
|
83
|
-
|
|
97
|
+
if (profile = task.environment_profile)
|
|
98
|
+
unless task.config.environment.profiles.key?(profile)
|
|
99
|
+
declared = task.config.environment.profiles.keys
|
|
100
|
+
listing = declared.any? ? "declares #{declared.join(", ")}" : "declares none"
|
|
101
|
+
raise ConfigError, "#{task.dir}: unknown environment #{profile} — bench.yml #{listing}"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
raise ConfigError, "#{task.dir}: environment #{profile} and a local #{ENVIRONMENT_DIR}/Dockerfile are mutually exclusive" if
|
|
105
|
+
task.environment_dockerfile.file?
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
raise ConfigError, "#{task.dir}: #{ENVIRONMENT_DIR}/Dockerfile is required when the bench declares no shared image or dockerfile and the task names no environment" unless
|
|
109
|
+
task.environment_profile || task.config.environment.image || task.config.environment.dockerfile || task.environment_dockerfile.file?
|
|
110
|
+
|
|
111
|
+
validate_steps!(task)
|
|
84
112
|
|
|
85
113
|
return unless task.test_files.empty?
|
|
86
114
|
|
|
@@ -88,6 +116,41 @@ module Lemans
|
|
|
88
116
|
"the verifier uploads it at verification time"
|
|
89
117
|
end
|
|
90
118
|
|
|
119
|
+
def validate_steps!(task)
|
|
120
|
+
if task.multistep? && task.steps < 2
|
|
121
|
+
raise ConfigError, "#{task.dir}: multistep: true but #{INSTRUCTION} holds a single section — " \
|
|
122
|
+
"separate step instructions with --- (the first section is the shared preamble)"
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
indexed_solutions = false
|
|
126
|
+
entries = task.dir.children
|
|
127
|
+
# Only step tests may carry an index inside tests/; a solution there is just a file.
|
|
128
|
+
entries += task.tests_dir.children.select { |entry| entry.file? && STEP_FILE.match(entry.basename.to_s)&.[](:test) } if task.tests_dir.directory?
|
|
129
|
+
entries.each do |entry|
|
|
130
|
+
match = STEP_FILE.match(entry.basename.to_s) or next
|
|
131
|
+
name = entry.relative_path_from(task.dir)
|
|
132
|
+
|
|
133
|
+
raise ConfigError, "#{task.dir}: #{name} is an indexed step file, but the task is not multistep: true" unless
|
|
134
|
+
task.multistep?
|
|
135
|
+
|
|
136
|
+
step = (match[:test] || match[:solution]).to_i
|
|
137
|
+
raise ConfigError, "#{task.dir}: #{name} names step #{step}, but the task has #{task.steps} steps" unless
|
|
138
|
+
step.between?(1, task.steps)
|
|
139
|
+
|
|
140
|
+
if match[:solution]
|
|
141
|
+
indexed_solutions = true
|
|
142
|
+
elsif step == task.steps
|
|
143
|
+
raise ConfigError, "#{task.dir}: #{name} indexes the final step — the final verification keeps " \
|
|
144
|
+
"the unindexed name"
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
return unless indexed_solutions && task.solution_files.any?
|
|
149
|
+
|
|
150
|
+
raise ConfigError, "#{task.dir}: #{FLAT_SOLUTION} is the whole task's solution while indexed step solutions " \
|
|
151
|
+
"chain step by step — ship one or the other, not both"
|
|
152
|
+
end
|
|
153
|
+
|
|
91
154
|
# Collisions would be resolved by upload order, so a task never gets to
|
|
92
155
|
# shadow the bench-wide copy.
|
|
93
156
|
def refuse_config_collisions!(task, declared, base)
|
|
@@ -103,7 +166,9 @@ module Lemans
|
|
|
103
166
|
|
|
104
167
|
attr_reader :config, :name, :dir
|
|
105
168
|
|
|
106
|
-
attr_accessor :difficulty, :tags, :description, :metadata
|
|
169
|
+
attr_accessor :difficulty, :tags, :description, :metadata, :environment_profile, :multistep
|
|
170
|
+
|
|
171
|
+
alias multistep? multistep
|
|
107
172
|
|
|
108
173
|
def initialize(config, name, dir: nil)
|
|
109
174
|
@config = config
|
|
@@ -113,13 +178,26 @@ module Lemans
|
|
|
113
178
|
@tags = []
|
|
114
179
|
@description = ""
|
|
115
180
|
@metadata = {}
|
|
181
|
+
@environment_profile = nil
|
|
182
|
+
@multistep = false
|
|
116
183
|
|
|
184
|
+
@step = nil
|
|
117
185
|
@dir = dir || config.tasks_dir.join(name)
|
|
118
186
|
end
|
|
119
187
|
|
|
120
|
-
#
|
|
188
|
+
# A copy of the task definition for a particular step
|
|
189
|
+
# (so we can generated correct paths and instructions)
|
|
190
|
+
def for_step(index) = dup.tap { it.step = index }
|
|
191
|
+
|
|
192
|
+
def final_step? = !multistep? || step == steps
|
|
193
|
+
|
|
194
|
+
def steps = multistep? ? sections.size - 1 : 1
|
|
195
|
+
|
|
121
196
|
def instruction
|
|
122
|
-
|
|
197
|
+
return body unless multistep?
|
|
198
|
+
raise ArgumentError, "#{name} is multistep — only a step projection (for_step) has an instruction" unless step
|
|
199
|
+
|
|
200
|
+
"#{sections[0].strip}\n\n#{sections.fetch(step).strip}\n"
|
|
123
201
|
end
|
|
124
202
|
|
|
125
203
|
def digest
|
|
@@ -147,14 +225,39 @@ module Lemans
|
|
|
147
225
|
|
|
148
226
|
# [absolute, remote-relative] pairs. Tests stay on the harness side while
|
|
149
227
|
# the agent works; uploaded into the sandbox only at verification.
|
|
228
|
+
#
|
|
229
|
+
# A multistep task keeps one tests/ directory: whatever in it carries no
|
|
230
|
+
# step index (helpers, fixtures) ships with every verified step, the
|
|
231
|
+
# step's own verification_test.N.rb ships as verification_test.rb, and
|
|
232
|
+
# the unindexed verification_test.rb stays with the final step.
|
|
150
233
|
def test_files
|
|
151
|
-
|
|
234
|
+
return final_test_files unless step && !final_step?
|
|
235
|
+
|
|
236
|
+
indexed = step_files(STEP_TESTS) + step_files(SHARED_STEP_TESTS, root: tests_dir)
|
|
237
|
+
return [] if indexed.empty?
|
|
238
|
+
|
|
239
|
+
shared_test_files.reject { |_, remote| FINAL_STEP_TESTS.include?(remote) } + indexed
|
|
152
240
|
end
|
|
153
241
|
|
|
242
|
+
def verifiable? = test_files.any?
|
|
243
|
+
|
|
244
|
+
# A lone whole-task solution is applied before the first step: intermediate
|
|
245
|
+
# gates must see it, and later steps have nothing left to add.
|
|
154
246
|
def solution_files
|
|
247
|
+
if step
|
|
248
|
+
return step_files(STEP_SOLUTIONS) if indexed_solutions?
|
|
249
|
+
return [] if multistep? && step > 1
|
|
250
|
+
end
|
|
251
|
+
|
|
155
252
|
solution_dir.directory? ? expand(solution_dir) : flat(FLAT_SOLUTION)
|
|
156
253
|
end
|
|
157
254
|
|
|
255
|
+
# True for the later steps of a multistep task whose lone solution already
|
|
256
|
+
# shipped with step 1
|
|
257
|
+
def solution_applied_earlier?
|
|
258
|
+
!!(step && step > 1 && !indexed_solutions? && (solution_dir.directory? || dir.join(FLAT_SOLUTION).file?))
|
|
259
|
+
end
|
|
260
|
+
|
|
158
261
|
def solution? = solution_files.any?
|
|
159
262
|
|
|
160
263
|
def tests_dir = dir.join(TESTS_DIR)
|
|
@@ -164,17 +267,62 @@ module Lemans
|
|
|
164
267
|
def environment_dockerfile = dir.join(ENVIRONMENT_DIR, "Dockerfile")
|
|
165
268
|
|
|
166
269
|
def environment_image
|
|
167
|
-
if
|
|
270
|
+
if (profile = environment.profiles[environment_profile])
|
|
271
|
+
if profile.image
|
|
272
|
+
Config::ImageSpec.registry(profile.image)
|
|
273
|
+
else
|
|
274
|
+
Config::ImageSpec.dockerfile(profile.dockerfile, slug: environment_profile)
|
|
275
|
+
end
|
|
276
|
+
elsif environment_dockerfile.file?
|
|
168
277
|
Config::ImageSpec.dockerfile(environment_dockerfile, slug: name)
|
|
169
|
-
elsif environment.
|
|
170
|
-
Config::ImageSpec.registry(environment.image)
|
|
171
|
-
else
|
|
278
|
+
elsif environment.dockerfile
|
|
172
279
|
Config::ImageSpec.dockerfile(environment.dockerfile, slug: "shared")
|
|
280
|
+
else
|
|
281
|
+
Config::ImageSpec.registry(environment.image)
|
|
173
282
|
end
|
|
174
283
|
end
|
|
175
284
|
|
|
285
|
+
protected attr_writer :step
|
|
286
|
+
|
|
176
287
|
private
|
|
177
288
|
|
|
289
|
+
attr_reader :step
|
|
290
|
+
|
|
291
|
+
def body
|
|
292
|
+
@body ||= dir.join(INSTRUCTION).read.sub(FRONTMATTER, "")
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
def sections
|
|
296
|
+
@sections ||= body.split(STEP_SEPARATOR)
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def indexed_solutions? = (1..steps).any? { step_files(STEP_SOLUTIONS, it).any? }
|
|
300
|
+
|
|
301
|
+
# A task may keep its final test flat at the root and use tests/ for shared files
|
|
302
|
+
def final_test_files
|
|
303
|
+
return flat(FLAT_TEST) unless tests_dir.directory?
|
|
304
|
+
|
|
305
|
+
shared_test_files + flat(FLAT_TEST)
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
# Everything under tests/ that no step claims for itself
|
|
309
|
+
def shared_test_files
|
|
310
|
+
return [] unless tests_dir.directory?
|
|
311
|
+
|
|
312
|
+
expand(tests_dir).reject { |_, remote| STEP_FILE.match?(remote) }
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
def step_files(patterns, index = step, root: dir)
|
|
316
|
+
patterns.flat_map do |pattern, remote|
|
|
317
|
+
path = root.join(format(pattern, index))
|
|
318
|
+
if remote == :dir
|
|
319
|
+
path.directory? ? expand(path) : []
|
|
320
|
+
else
|
|
321
|
+
path.file? ? [ [ path, remote ] ] : []
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
|
|
178
326
|
def own_setup_with_seed
|
|
179
327
|
declared = @declared_setup || Config::Setup.new
|
|
180
328
|
return declared unless seed? && declared.files.none? { |_, remote| remote == FLAT_SEED }
|
data/lib/lemans/trial/patch.rb
CHANGED
|
@@ -12,7 +12,7 @@ module Lemans
|
|
|
12
12
|
REMOTE_PATCH = "/tmp/lemans-agent.patch"
|
|
13
13
|
REMOTE_INDEX = "/tmp/lemans-patch.idx"
|
|
14
14
|
|
|
15
|
-
private attr_reader :task, :environment, :path, :workdir, :baseline, :timeout
|
|
15
|
+
private attr_reader :task, :environment, :path, :workdir, :baseline, :savepoint, :timeout
|
|
16
16
|
|
|
17
17
|
def initialize(task, environment, timeout: 300, path: "agent.patch")
|
|
18
18
|
@task = task
|
|
@@ -22,37 +22,75 @@ module Lemans
|
|
|
22
22
|
|
|
23
23
|
@workdir = task.environment.workdir
|
|
24
24
|
@baseline = nil
|
|
25
|
+
@savepoint = nil
|
|
25
26
|
end
|
|
26
27
|
|
|
27
28
|
def seal!
|
|
28
29
|
@baseline = write_tree
|
|
30
|
+
@savepoint = @baseline
|
|
29
31
|
end
|
|
30
32
|
|
|
31
33
|
# Must run before the verifier restores the graded surfaces: a patch taken
|
|
32
|
-
# after would not show what the agent did to them
|
|
33
|
-
|
|
34
|
+
# after would not show what the agent did to them. Diffs from the last
|
|
35
|
+
# savepoint (the sealed baseline until a multistep trial moves it), so a
|
|
36
|
+
# step's patch shows that step's work alone.
|
|
37
|
+
def collect!(result, store, path: @path)
|
|
38
|
+
return unless savepoint
|
|
39
|
+
|
|
40
|
+
after = write_tree
|
|
41
|
+
return unless after
|
|
42
|
+
|
|
43
|
+
save_diff(result, store, savepoint, after, path)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The compilation of every step: the whole run against the sealed baseline.
|
|
47
|
+
def compile!(result, store)
|
|
34
48
|
return unless baseline
|
|
35
49
|
|
|
36
50
|
after = write_tree
|
|
37
51
|
return unless after
|
|
38
52
|
|
|
39
|
-
|
|
53
|
+
save_diff(result, store, baseline, after, @path)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Marks the tree a finished step left: the next collect! diffs from here,
|
|
57
|
+
# and restore! comes back here. The mark is load-bearing, so failing to
|
|
58
|
+
# write it is an environment error, not a lost artifact.
|
|
59
|
+
def savepoint!
|
|
60
|
+
@savepoint = write_tree
|
|
61
|
+
raise InfrastructureError, "could not savepoint the tree the step left behind" unless savepoint
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# Puts the workdir back to the savepoint exactly: the verifier restored
|
|
65
|
+
# the graded surfaces from the baseline and may have littered the tree,
|
|
66
|
+
# and the next step's agent must find neither.
|
|
67
|
+
def restore!
|
|
68
|
+
return unless savepoint
|
|
69
|
+
|
|
70
|
+
environment.exec!(
|
|
71
|
+
"#{git} read-tree #{savepoint} && #{git} checkout-index -f -a && #{git} clean -fd && #{git} reset -q",
|
|
72
|
+
timeout:
|
|
73
|
+
)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def save_diff(result, store, from, to, destination)
|
|
79
|
+
diffed = environment.exec("#{git} diff --binary #{from} #{to} > #{REMOTE_PATCH}", timeout:)
|
|
40
80
|
return unless diffed.success?
|
|
41
81
|
|
|
42
82
|
Tempfile.create(%w[agent .patch]) do |file|
|
|
43
83
|
environment.download(REMOTE_PATCH, file.path)
|
|
44
|
-
store.save_artifact(result, Pathname(file.path), path:)
|
|
84
|
+
store.save_artifact(result, Pathname(file.path), path: destination)
|
|
45
85
|
end
|
|
46
86
|
|
|
47
87
|
environment.exec("rm -f #{REMOTE_PATCH} #{REMOTE_INDEX}", timeout:)
|
|
48
|
-
|
|
88
|
+
destination
|
|
49
89
|
rescue InfrastructureError => e
|
|
50
90
|
warn "lemans: could not collect the agent patch for #{result.id}: #{e.message}"
|
|
51
91
|
nil
|
|
52
92
|
end
|
|
53
93
|
|
|
54
|
-
private
|
|
55
|
-
|
|
56
94
|
# `safe.directory` because the sandbox may run the tree as a different user
|
|
57
95
|
# than built it, and git refuses to read a repo it thinks is someone else's.
|
|
58
96
|
def git = "git -c safe.directory='*' -C #{Shellwords.escape(workdir)}"
|