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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +15 -0
  3. data/README.md +14 -4
  4. data/exe/lemans-remote +22 -10
  5. data/lib/lemans/agents/miniswen.rb +2 -2
  6. data/lib/lemans/agents/oracle.rb +13 -7
  7. data/lib/lemans/cli/progress_reporter.rb +6 -1
  8. data/lib/lemans/cli/regrade.rb +193 -0
  9. data/lib/lemans/cli/report/aggregate.rb +18 -11
  10. data/lib/lemans/cli/report.rb +25 -8
  11. data/lib/lemans/cli/templates/bench/README.md +7 -2
  12. data/lib/lemans/cli/templates/bench/tasks/example-task/instruction.md +20 -9
  13. data/lib/lemans/cli/templates/bench/tasks/example-task/solution.1.patch +17 -0
  14. data/lib/lemans/cli/templates/bench/tasks/example-task/solution.2.patch +17 -0
  15. data/lib/lemans/cli/templates/bench/tasks/example-task/verification_test.1.rb +31 -0
  16. data/lib/lemans/cli/templates/bench/tasks/example-task/verification_test.rb +1 -1
  17. data/lib/lemans/cli.rb +38 -1
  18. data/lib/lemans/config/agent.rb +12 -0
  19. data/lib/lemans/config/environment.rb +31 -2
  20. data/lib/lemans/config/network_policy.rb +2 -0
  21. data/lib/lemans/config/setup.rb +6 -1
  22. data/lib/lemans/config/verifier.rb +18 -7
  23. data/lib/lemans/config.rb +45 -10
  24. data/lib/lemans/environment.rb +6 -3
  25. data/lib/lemans/environments/daytona/retries.rb +5 -3
  26. data/lib/lemans/environments/daytona/shell.rb +11 -1
  27. data/lib/lemans/environments/daytona.rb +16 -6
  28. data/lib/lemans/environments/docker.rb +3 -3
  29. data/lib/lemans/ext/deep_merge.rb +13 -0
  30. data/lib/lemans/result.rb +57 -7
  31. data/lib/lemans/runner.rb +1 -1
  32. data/lib/lemans/store.rb +7 -1
  33. data/lib/lemans/stores/fs.rb +9 -3
  34. data/lib/lemans/task_definition.rb +158 -10
  35. data/lib/lemans/trial/patch.rb +46 -8
  36. data/lib/lemans/trial/verifier/assets/eport-lemans.rb +6 -0
  37. data/lib/lemans/trial/verifier/assets/lemans_minitest_reporter.rb +44 -3
  38. data/lib/lemans/trial/verifier.rb +30 -3
  39. data/lib/lemans/trial.rb +89 -33
  40. data/lib/lemans/version.rb +1 -1
  41. data/lib/miniswen/agent.rb +21 -2
  42. data/lib/miniswen/version.rb +1 -1
  43. metadata +6 -2
  44. 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.new(**CostSource.members.to_h { [ it, nil ] }, **source)
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 graded!(reward)
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
- reward:, outcome: outcome.as_json, usage: usage&.as_json, duration:,
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
- result.graded!(data[:reward]) unless data[:reward].nil?
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
- def save_artifact(result, contents, path:)
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
@@ -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
- raise ConfigError, "#{task.dir}: #{ENVIRONMENT_DIR}/Dockerfile is required when the bench declares no shared image or dockerfile" unless
83
- task.config.environment.image || task.config.environment.dockerfile || task.environment_dockerfile.file?
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
- # The story alone: frontmatter is for the harness, never for the agent.
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
- @instruction ||= dir.join(INSTRUCTION).read.sub(FRONTMATTER, "")
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
- tests_dir.directory? ? expand(tests_dir) : flat(FLAT_TEST)
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 environment_dockerfile.file?
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.image
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 }
@@ -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
- def collect!(result, store)
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
- diffed = environment.exec("#{git} diff --binary #{baseline} #{after} > #{REMOTE_PATCH}", timeout:)
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
- path
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(checks: checks, failures: checks.reject { |_, status| status == "pass" }.keys)
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.skipped? then "skip"
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"