lemans 0.2.2 → 1.0.0.pre.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +30 -10
  3. data/exe/lemans-remote +161 -46
  4. data/lib/lemans/agent.rb +39 -0
  5. data/lib/lemans/agents/miniswen.rb +28 -25
  6. data/lib/lemans/agents/miniswen_installed.rb +13 -9
  7. data/lib/lemans/agents/nop.rb +3 -3
  8. data/lib/lemans/agents/oracle.rb +8 -8
  9. data/lib/lemans/cli/board_reporter.rb +16 -16
  10. data/lib/lemans/cli/progress_reporter.rb +19 -19
  11. data/lib/lemans/cli/report/aggregate.rb +117 -0
  12. data/lib/lemans/cli/report.rb +164 -0
  13. data/lib/lemans/cli.rb +58 -63
  14. data/lib/lemans/clobber.rb +17 -55
  15. data/lib/lemans/config/agent.rb +51 -0
  16. data/lib/lemans/config/conversion.rb +62 -0
  17. data/lib/lemans/config/environment.rb +39 -0
  18. data/lib/lemans/config/image_spec.rb +42 -0
  19. data/lib/lemans/config/network_policy.rb +55 -0
  20. data/lib/lemans/config/revision.rb +42 -0
  21. data/lib/lemans/config/setup.rb +57 -0
  22. data/lib/lemans/config/tree_digest.rb +26 -0
  23. data/lib/lemans/config/verifier.rb +72 -0
  24. data/lib/lemans/config.rb +107 -0
  25. data/lib/lemans/environment.rb +54 -0
  26. data/lib/lemans/environments/daytona/faraday_transfer.rb +172 -0
  27. data/lib/lemans/environments/daytona/sdk_tweaks.rb +1 -1
  28. data/lib/lemans/environments/daytona/shell.rb +1 -1
  29. data/lib/lemans/environments/daytona/snapshot_store.rb +11 -11
  30. data/lib/lemans/environments/daytona.rb +21 -35
  31. data/lib/lemans/result.rb +270 -0
  32. data/lib/lemans/runner/executor.rb +64 -0
  33. data/lib/lemans/runner/task.rb +62 -0
  34. data/lib/lemans/runner.rb +82 -0
  35. data/lib/lemans/store.rb +44 -0
  36. data/lib/lemans/stores/fs.rb +122 -0
  37. data/lib/lemans/task_definition.rb +194 -0
  38. data/lib/lemans/trial/patch.rb +76 -0
  39. data/lib/lemans/trial/setup.rb +66 -0
  40. data/lib/lemans/trial/snapshot.rb +57 -0
  41. data/lib/lemans/trial/verifier.rb +190 -0
  42. data/lib/lemans/trial.rb +113 -147
  43. data/lib/lemans/version.rb +1 -1
  44. data/lib/lemans.rb +3 -2
  45. data/lib/miniswen/trajectory.rb +2 -0
  46. metadata +58 -25
  47. data/lib/lemans/agents/base.rb +0 -30
  48. data/lib/lemans/bench.rb +0 -280
  49. data/lib/lemans/environments/base.rb +0 -55
  50. data/lib/lemans/network_policy.rb +0 -66
  51. data/lib/lemans/patch.rb +0 -70
  52. data/lib/lemans/restore_paths.rb +0 -21
  53. data/lib/lemans/results/aggregate.rb +0 -114
  54. data/lib/lemans/results/cost_source.rb +0 -13
  55. data/lib/lemans/results/outcome.rb +0 -36
  56. data/lib/lemans/results/report.rb +0 -149
  57. data/lib/lemans/results/sorting.rb +0 -24
  58. data/lib/lemans/results/tally.rb +0 -19
  59. data/lib/lemans/results/usage.rb +0 -24
  60. data/lib/lemans/run.rb +0 -152
  61. data/lib/lemans/setup.rb +0 -59
  62. data/lib/lemans/setup_files.rb +0 -36
  63. data/lib/lemans/snapshot.rb +0 -55
  64. data/lib/lemans/task.rb +0 -207
  65. data/lib/lemans/tree_digest.rb +0 -24
  66. data/lib/lemans/units.rb +0 -44
  67. data/lib/lemans/verifier.rb +0 -199
  68. /data/lib/lemans/{verifier → trial/verifier}/assets/eport-lemans.rb +0 -0
  69. /data/lib/lemans/{verifier → trial/verifier}/assets/lemans_minitest_reporter.rb +0 -0
@@ -0,0 +1,270 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "time"
5
+
6
+ module Lemans
7
+ # A single trial result record
8
+ class Result
9
+ class IncompatibleError < StandardError
10
+ end
11
+
12
+ class Outcome # :nodoc:
13
+ SCORED = %i[completed agent_timeout step_limit_reached cost_ceiling_reached].freeze
14
+ INVALID = %i[environment_error agent_error accounting_error verifier_error cancelled pending harness_crash].freeze
15
+
16
+ ALL = (SCORED + INVALID).freeze
17
+
18
+ ALL.each do |outcome|
19
+ define_method(:"#{outcome}?") { name == outcome }
20
+ end
21
+
22
+ attr_reader :status, :scored, :detail
23
+
24
+ # backward-compat
25
+ alias name status
26
+
27
+ alias scored? scored
28
+
29
+ def initialize(status, detail = nil)
30
+ status = status.to_sym
31
+ raise IncompatibleError, "Unrecognized outcome: #{status}" unless ALL.include?(status)
32
+
33
+ @status = status
34
+ @detail = detail
35
+ @scored = SCORED.include?(status)
36
+ end
37
+
38
+ def invalid? = !scored
39
+
40
+ def as_json(**)
41
+ {
42
+ name:,
43
+ scored:,
44
+ detail:
45
+ }.compact
46
+ end
47
+
48
+ def self.from_json(data)
49
+ status, detail = data.values_at(:name, :detail)
50
+ new(status, detail)
51
+ end
52
+ end
53
+
54
+ CostSource = Data.define(:name, :model, :priced_as, :registry)
55
+
56
+ Usage = Data.define(
57
+ :input_tokens, :output_tokens,
58
+ :cached_tokens, :steps,
59
+ :cost_usd, :cost_source
60
+ ) do
61
+ def as_json(**) = to_h.merge(cost_source: cost_source&.to_h).compact
62
+ end
63
+
64
+ def Usage.zero
65
+ new(input_tokens: 0, output_tokens: 0, cached_tokens: 0, steps: 0, cost_usd: 0.0, cost_source: nil)
66
+ end
67
+
68
+ def Usage.from_json(data)
69
+ # Older files carry a partial cost_source (just the name).
70
+ if (source = data[:cost_source])
71
+ cost_source = CostSource.new(**CostSource.members.to_h { [it, nil] }, **source)
72
+ end
73
+ new(
74
+ input_tokens: data[:input_tokens],
75
+ output_tokens: data[:output_tokens],
76
+ cached_tokens: data[:cached_tokens],
77
+ steps: data[:steps],
78
+ cost_usd: data[:cost_usd],
79
+ cost_source:
80
+ )
81
+ end
82
+
83
+ class Phase # :nodoc:
84
+ attr_reader :name, :started_at, :finished_at
85
+
86
+ alias finished? finished_at
87
+
88
+ def initialize(name, started_at: nil, finished_at: nil)
89
+ @name = name.to_sym
90
+ @started_at = started_at || Time.now.utc
91
+ @finished_at = finished_at
92
+ end
93
+
94
+ def finish!(time = nil)
95
+ @finished_at = time || Time.now.utc
96
+ end
97
+
98
+ def as_json(**)
99
+ {
100
+ name:,
101
+ started_at: started_at.iso8601(6),
102
+ finished_at: finished_at&.iso8601(6)
103
+ }
104
+ end
105
+
106
+ def self.from_json(data)
107
+ name, started_at_str, finished_at_str = data.values_at(:name, :started_at, :finished_at)
108
+
109
+ started_at = Time.parse(started_at_str) if started_at_str
110
+ finished_at = Time.parse(finished_at_str) if finished_at_str
111
+
112
+ new(name, started_at:, finished_at:)
113
+ end
114
+ end
115
+
116
+ Revision = Data.define(:commit, :dirty) do
117
+ def as_json(**) = to_h
118
+ end
119
+
120
+ # attributes that must be initialized/specified during construction
121
+ attr_reader :id, :task, :agent, :model, :index,
122
+ :profile_digest, :task_digest, :revision
123
+
124
+ attr_accessor :tags, :metadata
125
+
126
+ attr_reader :phases
127
+
128
+ # outcome-related attributes (we use setter-like methods, not accessors)
129
+ attr_reader :reward, :outcome, :usage
130
+
131
+ def initialize(task:, agent:, model:, id: nil, index: nil,
132
+ profile_digest: nil, task_digest: nil, revision: nil)
133
+ @task = task
134
+ @agent = agent
135
+ @model = model
136
+ @index = index
137
+ @profile_digest = profile_digest
138
+ @task_digest = task_digest
139
+ @revision = revision
140
+
141
+ @tags = []
142
+ @metadata = {}
143
+ @phases = []
144
+
145
+ @id = id || "#{task}__#{SecureRandom.alphanumeric(7)}"
146
+ @outcome = Outcome.new(:pending)
147
+ end
148
+
149
+ def phase_started(name, started_at = nil)
150
+ raise ArgumentError, "previous phase hasn't finished yet" if phases.last && !phases.last.finished?
151
+
152
+ phases << Phase.new(name, started_at:)
153
+ end
154
+
155
+ def phase_finished(name, finished_at = nil)
156
+ raise ArgumentError, "no current phase" if phases.empty?
157
+ raise ArgumentError, "can not finish phase #{name}: current one is #{phases.last.name}" if phases.last.name != name
158
+
159
+ phases.last.finish!(finished_at)
160
+ end
161
+
162
+ def started_at = phases.first&.started_at
163
+
164
+ def finished_at = phases.last&.finished_at
165
+
166
+ def status = outcome.status
167
+
168
+ def detail = outcome.detail
169
+
170
+ def scored? = outcome.scored?
171
+
172
+ def invalid? = outcome.invalid?
173
+
174
+ def duration
175
+ @duration || (finished_at && started_at && (finished_at - started_at).round(1))
176
+ end
177
+
178
+ def completed!(outcome, usage = nil, duration: nil)
179
+ @outcome = outcome.is_a?(Outcome) ? outcome : Outcome.new(outcome)
180
+ @usage = usage
181
+ @duration = duration if duration
182
+ self
183
+ end
184
+
185
+ def graded!(reward)
186
+ @reward = reward
187
+ self
188
+ end
189
+
190
+ def failed!(reason, detail)
191
+ # do not override already stored error
192
+ # (in case we have rescue cascades)
193
+ return self if outcome.invalid? && !outcome.pending?
194
+
195
+ @outcome = Outcome.new(reason, detail)
196
+ @reward = nil
197
+ self
198
+ end
199
+
200
+ def as_json(**)
201
+ {
202
+ trial: id, task:, agent:, model:, index:,
203
+ profile_digest:, task_digest:, revision: revision&.as_json,
204
+ lemans_version: VERSION,
205
+ tags:, metadata:, phases: phases.map(&:as_json),
206
+ reward:, outcome: outcome.as_json, usage: usage&.as_json, duration:,
207
+ started_at: started_at&.iso8601,
208
+ finished_at: finished_at&.iso8601
209
+ }.compact
210
+ end
211
+
212
+ class << self
213
+ def from_json(data)
214
+ result = new(
215
+ **data.slice(:task, :agent, :model, :index, :profile_digest, :task_digest),
216
+ # 0.2.x releases spell the id `run:`.
217
+ id: data[:trial] || data[:run],
218
+ revision: revision_from(data)
219
+ )
220
+ result.tags = data[:tags] || []
221
+ result.metadata = data[:metadata] || {}
222
+ phases_from(data).each { result.phases << it }
223
+ if data[:outcome]
224
+ result.completed!(
225
+ Outcome.from_json(data[:outcome]),
226
+ data[:usage] && Usage.from_json(data[:usage]),
227
+ # The recorded duration wins over the one derived from phases:
228
+ # older writers timed a slightly wider span.
229
+ duration: data[:duration] || data[:duration_sec]
230
+ )
231
+ end
232
+ result.graded!(data[:reward]) unless data[:reward].nil?
233
+ result
234
+ end
235
+
236
+ def from_task(definition, **)
237
+ config = definition.config
238
+ result = new(
239
+ task: definition.name,
240
+ agent: config.agent_name,
241
+ model: config.models.first,
242
+ profile_digest: config.digest,
243
+ task_digest: definition.digest,
244
+ revision: config.revision,
245
+ **
246
+ )
247
+ result.tags = definition.tags
248
+ result.metadata = definition.metadata
249
+ result
250
+ end
251
+
252
+ private
253
+
254
+ # Legacy files spell revision `bench:`.
255
+ def revision_from(data)
256
+ raw = data[:revision] || data[:bench]
257
+ raw && Revision.new(commit: raw[:commit], dirty: raw[:dirty])
258
+ end
259
+
260
+ # Legacy files record phases as a name-keyed mapping.
261
+ def phases_from(data)
262
+ case (raw = data[:phases])
263
+ when Hash then raw.map { |name, times| Phase.from_json({ name: }.merge(times)) }
264
+ when Array then raw.map { Phase.from_json(it) }
265
+ else []
266
+ end
267
+ end
268
+ end
269
+ end
270
+ end
@@ -0,0 +1,64 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent"
4
+
5
+ module Lemans
6
+ class Runner
7
+ # A default (Threaded) concurrent executor for tasks
8
+ class Executor
9
+ Results = Data.define(:buffer) do
10
+ def results = buffer.to_a
11
+ end
12
+
13
+ private attr_reader :concurrency, :queue, :results, :pool
14
+
15
+ def initialize(concurrency)
16
+ @concurrency = concurrency
17
+ @queue = Queue.new
18
+ @results = Results.new(Concurrent::Array.new)
19
+ @pool = nil
20
+ end
21
+
22
+ def start
23
+ @pool = Array.new(concurrency) do
24
+ Thread.new { drain }
25
+ end
26
+ results
27
+ end
28
+
29
+ def <<(task)
30
+ queue << task
31
+ end
32
+
33
+ def terminate
34
+ queue.clear
35
+ queue.close
36
+ pool.each { it.raise(Shutdown) if it.alive? }
37
+ pool.each(&:join)
38
+ end
39
+
40
+ def shutdown
41
+ queue.close
42
+ pool.each(&:join)
43
+ raise @abort if @abort
44
+ end
45
+
46
+ private
47
+
48
+ def drain
49
+ Thread.current.report_on_exception = false
50
+ while (task = queue.pop)
51
+ begin
52
+ results.buffer << task.run
53
+ rescue StandardError => e
54
+ @abort ||= e
55
+ queue.clear
56
+ queue.close
57
+ end
58
+ end
59
+ rescue Shutdown
60
+ # ignore: our own stop signal coming back
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "forwardable"
4
+
5
+ module Lemans
6
+ class Runner
7
+ # A single task to run: a thin wrapper owning status, reporting, and
8
+ # result persistence; the actual work is the Trial's.
9
+ class Task
10
+ extend Forwardable
11
+
12
+ attr_reader :model, :index, :status, :result
13
+
14
+ RUN_STATUSES = %i[pending running finished].freeze
15
+
16
+ RUN_STATUSES.each do |name|
17
+ define_method(:"#{name}?") { status == name }
18
+ end
19
+
20
+ def_delegators :definition, :name, :config
21
+ def_delegators :result, :id
22
+
23
+ private attr_reader :definition, :store, :reporter
24
+
25
+ def initialize(model, task_definition, index: 0, store: nil, reporter: nil)
26
+ @model = model
27
+ @definition = task_definition
28
+ @index = index
29
+ @store = store
30
+ @reporter = reporter
31
+ @status = :pending
32
+
33
+ # prepare the result object: it's used by the actual execution down the stack
34
+ @result = Result.from_task(definition, index:, model: model || config.models.first)
35
+ end
36
+
37
+ def with_reporter(reporter)
38
+ @reporter = reporter
39
+ self
40
+ end
41
+
42
+ def run
43
+ @status = :running
44
+ reporter&.record(:started, self)
45
+
46
+ execute!
47
+
48
+ @status = :finished
49
+ reporter&.record(:finished, result)
50
+ result
51
+ ensure
52
+ store&.save(result)
53
+ end
54
+
55
+ private
56
+
57
+ def execute!
58
+ Trial.new(definition, model, store:, result:).run
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module Lemans
6
+ # Runner orchestrates tasks execution
7
+ class Runner
8
+ # Injected into workers to abandon in-flight tasks on ^C: an Exception,
9
+ # not a StandardError, so task-level rescues cannot swallow it.
10
+ class Shutdown < Exception; end # rubocop:disable Lint/InheritException
11
+
12
+ Summary = Struct.new(:results, :interrupted, keyword_init: true) do
13
+ def status
14
+ return :interrupted if interrupted
15
+ return :invalid if results.any?(&:invalid?)
16
+
17
+ :ok
18
+ end
19
+ end
20
+
21
+ attr_reader :config, :tasks, :store, :reporter
22
+
23
+ private attr_reader :resuming, :executor
24
+
25
+ def initialize(config, tasks, store: nil, reporter: nil, executor: nil, resume: false)
26
+ @config = config
27
+ @tasks = tasks
28
+ @store = store
29
+ @reporter = reporter
30
+ @executor = executor || Executor.new(config.concurrency)
31
+ @resuming = resume
32
+ end
33
+
34
+ def resuming? = @resuming
35
+
36
+ def attempts
37
+ @attempts ||= config.agent.models.flat_map do |model|
38
+ @tasks.flat_map do |task|
39
+ completed = resuming? ? completed_attempts(task, model) : 0
40
+ ((completed + 1)..config.attempts).map { Task.new(model, task, store:, index: it) }
41
+ end
42
+ end
43
+ end
44
+
45
+ def run(reporter = nil)
46
+ @reporter = reporter if reporter
47
+
48
+ store&.setup
49
+
50
+ results_handle = executor.start
51
+ interrupted = false
52
+ begin
53
+ attempts.shuffle.each { executor << it.with_reporter(reporter) }
54
+ executor.shutdown
55
+ rescue Interrupt
56
+ interrupted = true
57
+ reporter ? reporter.record(:interrupted) : warn("Interrupted. Exiting...")
58
+ executor.terminate
59
+ end
60
+
61
+ results = results_handle.results
62
+ Summary.new(results:, interrupted:)
63
+ end
64
+
65
+ private
66
+
67
+ def completed_attempts(task, model)
68
+ completed_runs.count do |run|
69
+ run.task == task.name &&
70
+ run.model == (model || config.agent.model) &&
71
+ run.agent == config.agent_name &&
72
+ run.profile_digest == config.digest &&
73
+ run.task_digest == task.digest &&
74
+ run.scored?
75
+ end
76
+ end
77
+
78
+ def completed_runs
79
+ @completed_runs ||= store&.fetch || []
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lemans
4
+ # Store is responsible for storing trial results and
5
+ # querying them later.
6
+ class Store
7
+ # Prepares the store
8
+ def setup
9
+ # no-op
10
+ end
11
+
12
+ # Returns all run records
13
+ def fetch
14
+ raise NotImplementedError
15
+ end
16
+
17
+ # Returns the run records matching the filters
18
+ def query(**filters)
19
+ raise NotImplementedError
20
+ end
21
+
22
+ # Returns stub records for results that exist but cannot be read
23
+ def unreadable
24
+ []
25
+ end
26
+
27
+ # Deletes the result with everything it stored (artifacts included).
28
+ # Returns something truthy on success, nil otherwise
29
+ def delete(result)
30
+ raise NotImplementedError
31
+ end
32
+
33
+ # Persist the result
34
+ def save(result)
35
+ raise NotImplementedError
36
+ end
37
+
38
+ # Persist the result's file artifact
39
+ # (contents could be eiher IO (file) or text).
40
+ def save_artifact(result, contents, path:)
41
+ raise NotImplementedError
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "pathname"
6
+ require "securerandom"
7
+
8
+ module Lemans
9
+ module Stores
10
+ # A file-system store (default)
11
+ class FS < Store
12
+ FILENAME = "result.json"
13
+
14
+ private attr_reader :root
15
+
16
+ def initialize(root)
17
+ super()
18
+ @root = Pathname(root)
19
+ end
20
+
21
+ def setup
22
+ root.mkpath
23
+ rescue SystemCallError => e
24
+ raise ConfigError, "cannot use runs directory #{root}: #{e.message}"
25
+ end
26
+
27
+ def fetch
28
+ root.glob("**/#{FILENAME}").filter_map { to_record(it) }
29
+ end
30
+
31
+ # The file system keeps no index, so filtering happens in memory.
32
+ def query(task: nil, agent: nil, model: nil, tags: nil)
33
+ results = fetch
34
+ results.select! { Array(task).include?(it.task) } if task
35
+ results.select! { it.agent == agent } if agent
36
+ results.select! { it.model == model } if model
37
+ results.select! { Array(tags).intersect?(it.tags) } if tags
38
+ results
39
+ end
40
+
41
+ RESULT_ID = /\A(?<task>.+)__[A-Za-z0-9]{7}\z/
42
+
43
+ def unreadable
44
+ root.glob("**/#{FILENAME}").filter_map do |path|
45
+ next if to_record(path)
46
+
47
+ id = path.dirname.basename.to_s
48
+ Result.new(task: RESULT_ID.match(id)&.[](:task), agent: nil, model: nil, id:)
49
+ end
50
+ end
51
+
52
+ def delete(result)
53
+ dir = root.glob("**/#{result.id}").find(&:directory?)
54
+ return unless dir
55
+
56
+ FileUtils.remove_entry(dir.to_s)
57
+ prune_empty_parents(dir.parent)
58
+ dir
59
+ rescue SystemCallError => e
60
+ warn "lemans: could not delete #{result.id}: #{e.message}"
61
+ nil
62
+ end
63
+
64
+ def save(result)
65
+ atomic_write(result_dir(result).join(FILENAME), "#{JSON.pretty_generate(result.as_json)}\n")
66
+ rescue SystemCallError, JSON::GeneratorError => e
67
+ # runs_dir unwritable, disk full
68
+ raise ConfigError, "cannot record trial #{result.id}: #{e.message}"
69
+ end
70
+
71
+ def save_artifact(result, contents, path:)
72
+ destination = result_dir(result).join(path)
73
+ if destination.exist?
74
+ warn "lemans: artifact #{path} collides with an existing file and was dropped"
75
+ return
76
+ end
77
+
78
+ destination.dirname.mkpath
79
+ contents.is_a?(String) ? destination.write(contents) : IO.copy_stream(contents, destination)
80
+ destination
81
+ rescue SystemCallError => e
82
+ warn "lemans: could not save artifact #{path} for #{result.id}: #{e.message}"
83
+ nil
84
+ end
85
+
86
+ private
87
+
88
+ def prune_empty_parents(dir)
89
+ while dir != root && dir.children.empty?
90
+ dir.rmdir
91
+ dir = dir.parent
92
+ end
93
+ rescue SystemCallError
94
+ nil
95
+ end
96
+
97
+ def to_record(json_path)
98
+ data = JSON.parse(json_path.read, symbolize_names: true)
99
+
100
+ Result.from_json(data)
101
+ rescue JSON::ParserError, SystemCallError, IOError, Result::IncompatibleError
102
+ nil
103
+ end
104
+
105
+ # --resume treats any result.json as a finished attempt, so the write must
106
+ # be atomic: a rename is either all there or not there at all.
107
+ def atomic_write(path, content)
108
+ path.dirname.mkpath
109
+ tmp = path.dirname.join(".#{path.basename}.#{Process.pid}.#{SecureRandom.hex(4)}")
110
+ tmp.write(content)
111
+ tmp.rename(path)
112
+ ensure
113
+ tmp&.delete if tmp&.exist?
114
+ end
115
+
116
+ # result.json is stored at <root>/<model-short>/<result-id>
117
+ def result_dir(result)
118
+ root.join((result.model || result.agent).to_s.split("/").last, result.id)
119
+ end
120
+ end
121
+ end
122
+ end