lemans 1.1.0 → 1.3.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 +15 -0
- data/README.md +14 -4
- data/exe/lemans-remote +22 -10
- data/lib/lemans/agents/miniswen.rb +2 -2
- data/lib/lemans/agents/oracle.rb +13 -7
- data/lib/lemans/cli/progress_reporter.rb +6 -1
- data/lib/lemans/cli/regrade.rb +193 -0
- data/lib/lemans/cli/report/aggregate.rb +18 -11
- data/lib/lemans/cli/report.rb +25 -8
- data/lib/lemans/cli/templates/bench/README.md +7 -2
- 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/cli.rb +38 -1
- data/lib/lemans/config/agent.rb +12 -0
- data/lib/lemans/config/environment.rb +31 -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 +45 -10
- data/lib/lemans/environment.rb +6 -3
- data/lib/lemans/environments/daytona/retries.rb +5 -3
- data/lib/lemans/environments/daytona/shell.rb +11 -1
- data/lib/lemans/environments/daytona.rb +16 -6
- data/lib/lemans/environments/docker.rb +3 -3
- data/lib/lemans/ext/deep_merge.rb +13 -0
- data/lib/lemans/result.rb +57 -7
- data/lib/lemans/runner.rb +1 -1
- data/lib/lemans/store.rb +7 -1
- data/lib/lemans/stores/fs.rb +9 -3
- data/lib/lemans/task_definition.rb +158 -10
- data/lib/lemans/trial/patch.rb +46 -8
- data/lib/lemans/trial/verifier/assets/eport-lemans.rb +6 -0
- data/lib/lemans/trial/verifier/assets/lemans_minitest_reporter.rb +44 -3
- data/lib/lemans/trial/verifier.rb +30 -3
- data/lib/lemans/trial.rb +89 -33
- data/lib/lemans/version.rb +1 -1
- data/lib/miniswen/agent.rb +21 -2
- data/lib/miniswen/version.rb +1 -1
- metadata +6 -2
- data/lib/lemans/cli/templates/bench/tasks/example-task/solution.patch +0 -7
data/lib/lemans/result.rb
CHANGED
|
@@ -51,7 +51,12 @@ module Lemans
|
|
|
51
51
|
end
|
|
52
52
|
end
|
|
53
53
|
|
|
54
|
-
CostSource = Data.define(:name, :model, :priced_as, :registry)
|
|
54
|
+
CostSource = Data.define(:name, :model, :priced_as, :registry) do
|
|
55
|
+
# Build a cost source record from a possibly partial Hash
|
|
56
|
+
def self.build(**source)
|
|
57
|
+
new(**self.members.to_h { [ it, nil ] }, **source)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
55
60
|
|
|
56
61
|
Usage = Data.define(
|
|
57
62
|
:input_tokens, :output_tokens,
|
|
@@ -59,6 +64,18 @@ module Lemans
|
|
|
59
64
|
:cost_usd, :cost_source
|
|
60
65
|
) do
|
|
61
66
|
def as_json(**) = to_h.merge(cost_source: cost_source&.to_h).compact
|
|
67
|
+
|
|
68
|
+
# A multistep trial's totals; an unknown step cost makes the sum unknown.
|
|
69
|
+
def +(other)
|
|
70
|
+
self.class.new(
|
|
71
|
+
input_tokens: input_tokens + other.input_tokens,
|
|
72
|
+
output_tokens: output_tokens + other.output_tokens,
|
|
73
|
+
cached_tokens: cached_tokens + other.cached_tokens,
|
|
74
|
+
steps: steps + other.steps,
|
|
75
|
+
cost_usd: cost_usd && other.cost_usd && cost_usd + other.cost_usd,
|
|
76
|
+
cost_source: other.cost_source || cost_source
|
|
77
|
+
)
|
|
78
|
+
end
|
|
62
79
|
end
|
|
63
80
|
|
|
64
81
|
def Usage.zero
|
|
@@ -68,7 +85,7 @@ module Lemans
|
|
|
68
85
|
def Usage.from_json(data)
|
|
69
86
|
# Older files carry a partial cost_source (just the name).
|
|
70
87
|
if (source = data[:cost_source])
|
|
71
|
-
cost_source = CostSource.
|
|
88
|
+
cost_source = CostSource.build(**source)
|
|
72
89
|
end
|
|
73
90
|
new(
|
|
74
91
|
input_tokens: data[:input_tokens],
|
|
@@ -95,6 +112,8 @@ module Lemans
|
|
|
95
112
|
@finished_at = time || Time.now.utc
|
|
96
113
|
end
|
|
97
114
|
|
|
115
|
+
def duration = finished_at && (finished_at - started_at).round(1)
|
|
116
|
+
|
|
98
117
|
def as_json(**)
|
|
99
118
|
{
|
|
100
119
|
name:,
|
|
@@ -117,16 +136,26 @@ module Lemans
|
|
|
117
136
|
def as_json(**) = to_h
|
|
118
137
|
end
|
|
119
138
|
|
|
139
|
+
Step = Data.define(:outcome, :usage, :duration) do
|
|
140
|
+
def as_json(**) = { outcome: outcome.as_json, usage: usage&.as_json, duration: }.compact
|
|
141
|
+
|
|
142
|
+
def self.from_json(data)
|
|
143
|
+
new(outcome: Outcome.from_json(data[:outcome]),
|
|
144
|
+
usage: data[:usage] && Usage.from_json(data[:usage]),
|
|
145
|
+
duration: data[:duration])
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
120
149
|
# attributes that must be initialized/specified during construction
|
|
121
150
|
attr_reader :id, :task, :agent, :model, :index,
|
|
122
151
|
:profile_digest, :task_digest, :revision
|
|
123
152
|
|
|
124
153
|
attr_accessor :tags, :metadata
|
|
125
154
|
|
|
126
|
-
attr_reader :phases
|
|
155
|
+
attr_reader :phases, :steps
|
|
127
156
|
|
|
128
157
|
# outcome-related attributes (we use setter-like methods, not accessors)
|
|
129
|
-
attr_reader :reward, :outcome, :usage
|
|
158
|
+
attr_reader :reward, :credit, :outcome, :usage
|
|
130
159
|
|
|
131
160
|
def initialize(task:, agent:, model:, id: nil, index: nil,
|
|
132
161
|
profile_digest: nil, task_digest: nil, revision: nil)
|
|
@@ -141,6 +170,7 @@ module Lemans
|
|
|
141
170
|
@tags = []
|
|
142
171
|
@metadata = {}
|
|
143
172
|
@phases = []
|
|
173
|
+
@steps = nil
|
|
144
174
|
|
|
145
175
|
@id = id || "#{task}__#{SecureRandom.alphanumeric(7)}"
|
|
146
176
|
@outcome = Outcome.new(:pending)
|
|
@@ -184,8 +214,17 @@ module Lemans
|
|
|
184
214
|
self
|
|
185
215
|
end
|
|
186
216
|
|
|
187
|
-
def
|
|
217
|
+
def step_completed!(outcome, usage = nil, duration: nil)
|
|
218
|
+
outcome = outcome.is_a?(Outcome) ? outcome : Outcome.new(outcome)
|
|
219
|
+
@steps ||= []
|
|
220
|
+
steps << Step.new(outcome:, usage:, duration:)
|
|
221
|
+
# aggregate right away (so we don't lose data on failure)
|
|
222
|
+
completed!(outcome, aggregate_usage)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def graded!(reward, credit: reward)
|
|
188
226
|
@reward = reward
|
|
227
|
+
@credit = credit
|
|
189
228
|
self
|
|
190
229
|
end
|
|
191
230
|
|
|
@@ -196,16 +235,20 @@ module Lemans
|
|
|
196
235
|
|
|
197
236
|
@outcome = Outcome.new(reason, detail)
|
|
198
237
|
@reward = nil
|
|
238
|
+
@credit = nil
|
|
199
239
|
self
|
|
200
240
|
end
|
|
201
241
|
|
|
242
|
+
private def aggregate_usage = steps.filter_map(&:usage).reduce(:+)
|
|
243
|
+
|
|
202
244
|
def as_json(**)
|
|
203
245
|
{
|
|
204
246
|
trial: id, task:, agent:, model:, index:,
|
|
205
247
|
profile_digest:, task_digest:, revision: revision&.as_json,
|
|
206
248
|
lemans_version: VERSION,
|
|
207
249
|
tags:, metadata:, phases: phases.map(&:as_json),
|
|
208
|
-
|
|
250
|
+
steps: steps&.map(&:as_json),
|
|
251
|
+
reward:, credit:, outcome: outcome.as_json, usage: usage&.as_json, duration:,
|
|
209
252
|
started_at: started_at&.iso8601,
|
|
210
253
|
finished_at: finished_at&.iso8601
|
|
211
254
|
}.compact
|
|
@@ -222,6 +265,12 @@ module Lemans
|
|
|
222
265
|
result.tags = data[:tags] || []
|
|
223
266
|
result.metadata = data[:metadata] || {}
|
|
224
267
|
phases_from(data).each { result.phases << it }
|
|
268
|
+
|
|
269
|
+
# Steps first: the stored outcome/usage below override the aggregates.
|
|
270
|
+
data[:steps]&.map { Step.from_json(it) }&.each do |step|
|
|
271
|
+
result.step_completed!(step.outcome, step.usage, duration: step.duration)
|
|
272
|
+
end
|
|
273
|
+
|
|
225
274
|
if data[:outcome]
|
|
226
275
|
result.completed!(
|
|
227
276
|
Outcome.from_json(data[:outcome]),
|
|
@@ -231,7 +280,8 @@ module Lemans
|
|
|
231
280
|
duration: data[:duration] || data[:duration_sec]
|
|
232
281
|
)
|
|
233
282
|
end
|
|
234
|
-
|
|
283
|
+
|
|
284
|
+
result.graded!(data[:reward], credit: data[:credit] || data[:reward]) unless data[:reward].nil?
|
|
235
285
|
result
|
|
236
286
|
end
|
|
237
287
|
|
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/store.rb
CHANGED
|
@@ -37,7 +37,13 @@ module Lemans
|
|
|
37
37
|
|
|
38
38
|
# Persist the result's file artifact
|
|
39
39
|
# (contents could be eiher IO (file) or text).
|
|
40
|
-
|
|
40
|
+
# An existing artifact is kept unless force is set.
|
|
41
|
+
def save_artifact(result, contents, path:, force: false)
|
|
42
|
+
raise NotImplementedError
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Returns the artifact's text, nil when the result never stored it
|
|
46
|
+
def read_artifact(result, path)
|
|
41
47
|
raise NotImplementedError
|
|
42
48
|
end
|
|
43
49
|
end
|
data/lib/lemans/stores/fs.rb
CHANGED
|
@@ -30,12 +30,13 @@ module Lemans
|
|
|
30
30
|
end
|
|
31
31
|
|
|
32
32
|
# The file system keeps no index, so filtering happens in memory.
|
|
33
|
-
def query(task: nil, agent: nil, model: nil, tags: nil)
|
|
33
|
+
def query(task: nil, agent: nil, model: nil, tags: nil, metadata: nil)
|
|
34
34
|
results = fetch
|
|
35
35
|
results.select! { Array(task).include?(it.task) } if task
|
|
36
36
|
results.select! { it.agent == agent } if agent
|
|
37
37
|
results.select! { it.model == model } if model
|
|
38
38
|
results.select! { Array(tags).intersect?(it.tags) } if tags
|
|
39
|
+
results.select! { |result| metadata.all? { |key, value| result.metadata.transform_keys(&:to_s)[key].to_s == value } } if metadata
|
|
39
40
|
results
|
|
40
41
|
end
|
|
41
42
|
|
|
@@ -69,9 +70,9 @@ module Lemans
|
|
|
69
70
|
raise ConfigError, "cannot record trial #{result.id}: #{e.message}"
|
|
70
71
|
end
|
|
71
72
|
|
|
72
|
-
def save_artifact(result, contents, path:)
|
|
73
|
+
def save_artifact(result, contents, path:, force: false)
|
|
73
74
|
destination = result_dir(result).join(path)
|
|
74
|
-
if destination.exist?
|
|
75
|
+
if destination.exist? && !force
|
|
75
76
|
warn "lemans: artifact #{path} collides with an existing file and was dropped"
|
|
76
77
|
return
|
|
77
78
|
end
|
|
@@ -84,6 +85,11 @@ module Lemans
|
|
|
84
85
|
nil
|
|
85
86
|
end
|
|
86
87
|
|
|
88
|
+
def read_artifact(result, path)
|
|
89
|
+
file = result_dir(result).join(path)
|
|
90
|
+
file.read if file.file?
|
|
91
|
+
end
|
|
92
|
+
|
|
87
93
|
private
|
|
88
94
|
|
|
89
95
|
def filtered(text) = filterer ? filterer.filter(text) : text
|
|
@@ -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)}"
|
|
@@ -3,6 +3,12 @@
|
|
|
3
3
|
# Loaded when a verifier command opts in with `ruby -report-lemans …`
|
|
4
4
|
# (that is `-r eport-lemans`, resolved from /tests on the LOAD_PATH).
|
|
5
5
|
module LemansReport
|
|
6
|
+
class << self
|
|
7
|
+
attr_accessor :base_credit
|
|
8
|
+
|
|
9
|
+
def points = @points ||= {}
|
|
10
|
+
end
|
|
11
|
+
|
|
6
12
|
def self.registered? = @registered
|
|
7
13
|
|
|
8
14
|
def self.register
|
|
@@ -3,6 +3,25 @@
|
|
|
3
3
|
require "json"
|
|
4
4
|
|
|
5
5
|
module LemansReport
|
|
6
|
+
# A check the task wants recorded but not graded
|
|
7
|
+
# Inherit from Skip to let the tests pass.
|
|
8
|
+
class AllowedFailure < Minitest::Skip; end
|
|
9
|
+
|
|
10
|
+
module Assertions
|
|
11
|
+
# Allow failing minitest assertions inside the block (but halt and record them as allowed failures not affected the grade)
|
|
12
|
+
def allow_failure(points: 1)
|
|
13
|
+
check = "#{self.class}##{name}"
|
|
14
|
+
raise ArgumentError, "#{check} calls allow_failure twice: one allowed failure per test" if LemansReport.points.key?(check)
|
|
15
|
+
|
|
16
|
+
LemansReport.points[check] = points
|
|
17
|
+
yield
|
|
18
|
+
rescue Minitest::Skip
|
|
19
|
+
raise
|
|
20
|
+
rescue Minitest::Assertion => e
|
|
21
|
+
raise AllowedFailure, e.message
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
6
25
|
# Appends every Minitest result to $LOGS/checks.json. Required by
|
|
7
26
|
# eport-lemans once Minitest is loaded; never load this file directly.
|
|
8
27
|
class Reporter < Minitest::AbstractReporter
|
|
@@ -22,16 +41,24 @@ module LemansReport
|
|
|
22
41
|
return if graded.empty? && prior.empty?
|
|
23
42
|
|
|
24
43
|
checks = prior.merge(graded.to_h { [ name(it), status(it) ] }).sort.to_h
|
|
44
|
+
allowed = existing.fetch("allowed_failures", {}).merge(graded.select { allowed?(it) }.to_h { [ name(it), it.failure.message ] }).sort.to_h
|
|
25
45
|
File.write(
|
|
26
46
|
File.join(@dir, "checks.json"),
|
|
27
|
-
JSON.pretty_generate(
|
|
47
|
+
JSON.pretty_generate(
|
|
48
|
+
{
|
|
49
|
+
checks: checks,
|
|
50
|
+
failures: checks.reject { |_, status| status == "pass" || status == ALLOWED }.keys,
|
|
51
|
+
allowed_failures: allowed,
|
|
52
|
+
grading:
|
|
53
|
+
}.compact
|
|
54
|
+
)
|
|
28
55
|
)
|
|
29
56
|
end
|
|
30
57
|
|
|
31
58
|
# A skip inside the harness-shipped tests is an unverified requirement and
|
|
32
59
|
# fails the run. The app's own suite keeps vanilla skip semantics.
|
|
33
60
|
def passed?
|
|
34
|
-
@results.none? { |result| graded?(result) && result.skipped? }
|
|
61
|
+
@results.none? { |result| graded?(result) && result.skipped? && !allowed?(result) }
|
|
35
62
|
end
|
|
36
63
|
|
|
37
64
|
private
|
|
@@ -42,6 +69,15 @@ module LemansReport
|
|
|
42
69
|
dir && result.source_location.first.to_s.start_with?("#{dir.chomp("/")}/")
|
|
43
70
|
end
|
|
44
71
|
|
|
72
|
+
def grading
|
|
73
|
+
prior = existing.fetch("grading", {})
|
|
74
|
+
base_credit = LemansReport.base_credit || prior["base_credit"]
|
|
75
|
+
points = prior.fetch("points", {}).merge(LemansReport.points).sort.to_h
|
|
76
|
+
return if base_credit.nil? && points.empty?
|
|
77
|
+
|
|
78
|
+
{ base_credit:, points: }.compact
|
|
79
|
+
end
|
|
80
|
+
|
|
45
81
|
def existing
|
|
46
82
|
JSON.parse(File.read(File.join(@dir, "checks.json")))
|
|
47
83
|
rescue StandardError
|
|
@@ -50,8 +86,13 @@ module LemansReport
|
|
|
50
86
|
|
|
51
87
|
def name(result) = "#{result.klass}##{result.name}"
|
|
52
88
|
|
|
89
|
+
ALLOWED = "fail (allowed)"
|
|
90
|
+
|
|
91
|
+
def allowed?(result) = result.skipped? && result.failure.is_a?(AllowedFailure)
|
|
92
|
+
|
|
53
93
|
def status(result)
|
|
54
|
-
if result
|
|
94
|
+
if allowed?(result) then ALLOWED
|
|
95
|
+
elsif result.skipped? then "skip"
|
|
55
96
|
elsif result.error? then "error"
|
|
56
97
|
elsif result.passed? then "pass"
|
|
57
98
|
else "fail"
|