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.
- checksums.yaml +4 -4
- data/README.md +30 -10
- data/exe/lemans-remote +161 -46
- data/lib/lemans/agent.rb +39 -0
- data/lib/lemans/agents/miniswen.rb +28 -25
- data/lib/lemans/agents/miniswen_installed.rb +13 -9
- data/lib/lemans/agents/nop.rb +3 -3
- data/lib/lemans/agents/oracle.rb +8 -8
- data/lib/lemans/cli/board_reporter.rb +16 -16
- data/lib/lemans/cli/progress_reporter.rb +19 -19
- data/lib/lemans/cli/report/aggregate.rb +117 -0
- data/lib/lemans/cli/report.rb +164 -0
- data/lib/lemans/cli.rb +58 -63
- data/lib/lemans/clobber.rb +17 -55
- data/lib/lemans/config/agent.rb +51 -0
- data/lib/lemans/config/conversion.rb +62 -0
- data/lib/lemans/config/environment.rb +39 -0
- data/lib/lemans/config/image_spec.rb +42 -0
- data/lib/lemans/config/network_policy.rb +55 -0
- data/lib/lemans/config/revision.rb +42 -0
- data/lib/lemans/config/setup.rb +57 -0
- data/lib/lemans/config/tree_digest.rb +26 -0
- data/lib/lemans/config/verifier.rb +72 -0
- data/lib/lemans/config.rb +107 -0
- data/lib/lemans/environment.rb +54 -0
- data/lib/lemans/environments/daytona/faraday_transfer.rb +172 -0
- data/lib/lemans/environments/daytona/sdk_tweaks.rb +1 -1
- data/lib/lemans/environments/daytona/shell.rb +1 -1
- data/lib/lemans/environments/daytona/snapshot_store.rb +11 -11
- data/lib/lemans/environments/daytona.rb +21 -35
- data/lib/lemans/result.rb +270 -0
- data/lib/lemans/runner/executor.rb +64 -0
- data/lib/lemans/runner/task.rb +62 -0
- data/lib/lemans/runner.rb +82 -0
- data/lib/lemans/store.rb +44 -0
- data/lib/lemans/stores/fs.rb +122 -0
- data/lib/lemans/task_definition.rb +194 -0
- data/lib/lemans/trial/patch.rb +76 -0
- data/lib/lemans/trial/setup.rb +66 -0
- data/lib/lemans/trial/snapshot.rb +57 -0
- data/lib/lemans/trial/verifier.rb +190 -0
- data/lib/lemans/trial.rb +113 -147
- data/lib/lemans/version.rb +1 -1
- data/lib/lemans.rb +3 -2
- data/lib/miniswen/trajectory.rb +2 -0
- metadata +58 -25
- data/lib/lemans/agents/base.rb +0 -30
- data/lib/lemans/bench.rb +0 -280
- data/lib/lemans/environments/base.rb +0 -55
- data/lib/lemans/network_policy.rb +0 -66
- data/lib/lemans/patch.rb +0 -70
- data/lib/lemans/restore_paths.rb +0 -21
- data/lib/lemans/results/aggregate.rb +0 -114
- data/lib/lemans/results/cost_source.rb +0 -13
- data/lib/lemans/results/outcome.rb +0 -36
- data/lib/lemans/results/report.rb +0 -149
- data/lib/lemans/results/sorting.rb +0 -24
- data/lib/lemans/results/tally.rb +0 -19
- data/lib/lemans/results/usage.rb +0 -24
- data/lib/lemans/run.rb +0 -152
- data/lib/lemans/setup.rb +0 -59
- data/lib/lemans/setup_files.rb +0 -36
- data/lib/lemans/snapshot.rb +0 -55
- data/lib/lemans/task.rb +0 -207
- data/lib/lemans/tree_digest.rb +0 -24
- data/lib/lemans/units.rb +0 -44
- data/lib/lemans/verifier.rb +0 -199
- /data/lib/lemans/{verifier → trial/verifier}/assets/eport-lemans.rb +0 -0
- /data/lib/lemans/{verifier → trial/verifier}/assets/lemans_minitest_reporter.rb +0 -0
|
@@ -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
|
data/lib/lemans/store.rb
ADDED
|
@@ -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
|