lemans 0.2.3 → 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 +94 -29
- 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
|
@@ -17,7 +17,7 @@ module Lemans
|
|
|
17
17
|
|
|
18
18
|
def initialize(tasks:, models:, attempts:, total: nil, out: $stderr)
|
|
19
19
|
@tasks = tasks
|
|
20
|
-
@models = models.map { short(
|
|
20
|
+
@models = models.map { short(it) }
|
|
21
21
|
@attempts = attempts
|
|
22
22
|
# Injected when known: under --resume the schedule is smaller than
|
|
23
23
|
# tasks × models × attempts.
|
|
@@ -41,20 +41,20 @@ module Lemans
|
|
|
41
41
|
self
|
|
42
42
|
end
|
|
43
43
|
|
|
44
|
-
def record(event, data)
|
|
44
|
+
def record(event, data = nil)
|
|
45
45
|
@lock.synchronize do
|
|
46
46
|
case event
|
|
47
47
|
when :started
|
|
48
48
|
@in_flight += 1
|
|
49
|
-
cell(data)[data
|
|
49
|
+
cell(data.name, data.model)[data.index - 1] = :running
|
|
50
50
|
when :finished
|
|
51
51
|
@in_flight -= 1
|
|
52
52
|
@done += 1
|
|
53
|
-
cell(data)[data
|
|
53
|
+
cell(data.task, data.model)[data.index - 1] = data
|
|
54
54
|
announce_error(data)
|
|
55
55
|
when :interrupted
|
|
56
56
|
erase
|
|
57
|
-
@out.puts "#{YELLOW}^C —
|
|
57
|
+
@out.puts "#{YELLOW}^C — abandoning #{@in_flight} in-flight trial(s)#{RESET}"
|
|
58
58
|
@drawn = 0
|
|
59
59
|
end
|
|
60
60
|
end
|
|
@@ -71,16 +71,16 @@ module Lemans
|
|
|
71
71
|
|
|
72
72
|
private
|
|
73
73
|
|
|
74
|
-
def announce_error(
|
|
75
|
-
return if
|
|
74
|
+
def announce_error(result)
|
|
75
|
+
return if result.scored? || result.detail.nil?
|
|
76
76
|
|
|
77
77
|
erase
|
|
78
|
-
@out.puts "\e[2K#{RED}#{
|
|
79
|
-
"#{
|
|
78
|
+
@out.puts "\e[2K#{RED}#{result.task}: #{result.status} — " \
|
|
79
|
+
"#{result.detail.to_s.lines.first.to_s.strip[0, MAX_DETAIL_CHARS]}#{RESET}"
|
|
80
80
|
@drawn = 0
|
|
81
81
|
end
|
|
82
82
|
|
|
83
|
-
def cell(
|
|
83
|
+
def cell(task, model) = @cells[[task, short(model)]]
|
|
84
84
|
|
|
85
85
|
# A bench may declare no model at all; nil must not reach ljust.
|
|
86
86
|
def short(model) = model.nil? ? "(default)" : model.to_s.split("/").last
|
|
@@ -90,12 +90,12 @@ module Lemans
|
|
|
90
90
|
task_width = (@tasks.map(&:length) + [4]).max
|
|
91
91
|
cell_width = ([@attempts, 3].max + 2)
|
|
92
92
|
lines = [header(task_width, cell_width)]
|
|
93
|
-
@tasks.each { lines << row(
|
|
93
|
+
@tasks.each { lines << row(it, task_width, cell_width) }
|
|
94
94
|
lines << "#{DIM}#{FRAMES[@frame % FRAMES.size]} #{@done}/#{@total} done " \
|
|
95
95
|
"· #{@in_flight} in flight#{RESET}"
|
|
96
96
|
|
|
97
97
|
erase
|
|
98
|
-
@out.print lines.map { "\e[2K#{
|
|
98
|
+
@out.print lines.map { "\e[2K#{it}" }.join("\n")
|
|
99
99
|
@drawn = lines.size
|
|
100
100
|
end
|
|
101
101
|
|
|
@@ -105,7 +105,7 @@ module Lemans
|
|
|
105
105
|
end
|
|
106
106
|
|
|
107
107
|
def header(task_width, cell_width)
|
|
108
|
-
"#{DIM}#{"task".ljust(task_width)} #{@models.map {
|
|
108
|
+
"#{DIM}#{"task".ljust(task_width)} #{@models.map { it.ljust([it.length, cell_width].max) }.join(" ")}#{RESET}"
|
|
109
109
|
end
|
|
110
110
|
|
|
111
111
|
# ljust would count the glyphs' invisible ANSI bytes, so cells pad by
|
|
@@ -114,7 +114,7 @@ module Lemans
|
|
|
114
114
|
cells = @models.map do |model|
|
|
115
115
|
states = @cells[[task, model]]
|
|
116
116
|
pad = [model.length, cell_width].max - states.size
|
|
117
|
-
states.map { glyph(
|
|
117
|
+
states.map { glyph(it) }.join + (" " * [pad, 0].max)
|
|
118
118
|
end
|
|
119
119
|
"#{task.ljust(task_width)} #{cells.join(" ")}"
|
|
120
120
|
end
|
|
@@ -124,8 +124,8 @@ module Lemans
|
|
|
124
124
|
when :queued then "#{DIM}·#{RESET}"
|
|
125
125
|
when :running then FRAMES[@frame % FRAMES.size]
|
|
126
126
|
else
|
|
127
|
-
if !state
|
|
128
|
-
elsif state
|
|
127
|
+
if !state.scored? then "#{RED}!#{RESET}"
|
|
128
|
+
elsif state.reward.to_f >= 1.0 then "#{GREEN}✔#{RESET}"
|
|
129
129
|
else "#{YELLOW}✘#{RESET}"
|
|
130
130
|
end
|
|
131
131
|
end
|
|
@@ -4,8 +4,8 @@ module Lemans
|
|
|
4
4
|
class CLI < Thor
|
|
5
5
|
# The pipe renderer: one plain line per event
|
|
6
6
|
class ProgressReporter
|
|
7
|
-
# say_status's verb column is 12 wide; the longer
|
|
8
|
-
# short verb here and keep their full name in the
|
|
7
|
+
# say_status's verb column is 12 wide; the longer status names get a
|
|
8
|
+
# short verb here and keep their full name in the board and the result.
|
|
9
9
|
STATUS_VERBS = {
|
|
10
10
|
completed: :completed,
|
|
11
11
|
agent_timeout: :timeout,
|
|
@@ -20,20 +20,19 @@ module Lemans
|
|
|
20
20
|
|
|
21
21
|
MAX_DETAIL_CHARS = 200
|
|
22
22
|
|
|
23
|
-
def initialize(shell:,
|
|
23
|
+
def initialize(shell:, tasks:)
|
|
24
24
|
@shell = shell
|
|
25
|
-
@task_width =
|
|
25
|
+
@task_width = (tasks.map(&:length) + [4]).max
|
|
26
26
|
end
|
|
27
27
|
|
|
28
28
|
def start = self
|
|
29
29
|
|
|
30
|
-
def record(event, data)
|
|
30
|
+
def record(event, data = nil)
|
|
31
31
|
case event
|
|
32
32
|
when :started then started(data)
|
|
33
33
|
when :finished then finished(data)
|
|
34
34
|
when :interrupted
|
|
35
|
-
@shell.say_status :interrupt,
|
|
36
|
-
"waiting for #{data[:in_flight]} in-flight trial(s), ^C again to abandon", :yellow
|
|
35
|
+
@shell.say_status :interrupt, "abandoning in-flight trial(s)", :yellow
|
|
37
36
|
end
|
|
38
37
|
end
|
|
39
38
|
|
|
@@ -41,26 +40,27 @@ module Lemans
|
|
|
41
40
|
|
|
42
41
|
private
|
|
43
42
|
|
|
44
|
-
def started(
|
|
45
|
-
|
|
46
|
-
|
|
43
|
+
def started(task)
|
|
44
|
+
attempts = task.config.attempts
|
|
45
|
+
attempt = "attempt #{task.index.to_s.rjust(attempts.to_s.length)}/#{attempts}"
|
|
46
|
+
@shell.say_status :run, "#{task.name.ljust(@task_width)} #{attempt} #{task.id}", :blue
|
|
47
47
|
end
|
|
48
48
|
|
|
49
|
-
def finished(
|
|
50
|
-
status =
|
|
51
|
-
@shell.say_status STATUS_VERBS.fetch(
|
|
52
|
-
"#{
|
|
53
|
-
color(
|
|
49
|
+
def finished(result)
|
|
50
|
+
status = result.scored? ? "reward=#{result.reward.inspect}" : result.status.to_s
|
|
51
|
+
@shell.say_status STATUS_VERBS.fetch(result.status, result.status),
|
|
52
|
+
"#{result.task.ljust(@task_width)} #{status.ljust(12)} #{result.duration}s",
|
|
53
|
+
color(result)
|
|
54
54
|
|
|
55
|
-
@shell.say_status :error, first_line(
|
|
55
|
+
@shell.say_status :error, first_line(result.detail), :red unless result.scored? || result.detail.nil?
|
|
56
56
|
end
|
|
57
57
|
|
|
58
58
|
def first_line(detail) = detail.to_s.lines.first.to_s.strip[0, MAX_DETAIL_CHARS]
|
|
59
59
|
|
|
60
|
-
def color(
|
|
61
|
-
return :red unless
|
|
60
|
+
def color(result)
|
|
61
|
+
return :red unless result.scored?
|
|
62
62
|
|
|
63
|
-
|
|
63
|
+
result.reward.to_f >= 1.0 ? :green : :yellow
|
|
64
64
|
end
|
|
65
65
|
end
|
|
66
66
|
end
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "csv"
|
|
4
|
+
|
|
5
|
+
module Lemans
|
|
6
|
+
class CLI < Thor
|
|
7
|
+
class Report
|
|
8
|
+
# Rolls trials up the way a leaderboard quotes them: solved out of
|
|
9
|
+
# attempts, median time, mean spend per run. Groups by any 1-3 of
|
|
10
|
+
# task, agent, model — "task-model" reads as two columns.
|
|
11
|
+
class Aggregate
|
|
12
|
+
KEYS = %i[task agent model].freeze
|
|
13
|
+
METRICS = %i[score time cost steps tokens].freeze
|
|
14
|
+
METRIC_SOURCES = { time: :duration, cost: :cost_usd, steps: :steps, tokens: :tokens }.freeze
|
|
15
|
+
|
|
16
|
+
attr_reader :report, :keys
|
|
17
|
+
|
|
18
|
+
def self.keys(spec)
|
|
19
|
+
keys = spec.to_s.split("-").map(&:to_sym)
|
|
20
|
+
return keys if keys.size.between?(1, 3) && keys.uniq == keys && (keys - KEYS).empty?
|
|
21
|
+
|
|
22
|
+
raise ConfigError, "--aggregate: expected 1-3 of #{KEYS.join(", ")} joined by dashes (got #{spec.inspect})"
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def initialize(report, keys:)
|
|
26
|
+
@report = report
|
|
27
|
+
@keys = keys
|
|
28
|
+
|
|
29
|
+
@groups = report.rows
|
|
30
|
+
.group_by { |row| keys.map { row[it] } }
|
|
31
|
+
.map { |values, group| build(values, group) }
|
|
32
|
+
.sort_by { |group| keys.map { group[it].to_s } }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def order_by!(column)
|
|
36
|
+
column = Report.sort_column(column, allowed: keys + METRICS)
|
|
37
|
+
@groups =
|
|
38
|
+
if keys.include?(column)
|
|
39
|
+
Report.sort_rows(@groups) { it[column].to_s }
|
|
40
|
+
elsif column == :score
|
|
41
|
+
Report.sort_rows(@groups, descending: true) { [Rational(it[:solved], it[:attempts]), it[:attempts]] }
|
|
42
|
+
else
|
|
43
|
+
Report.sort_rows(@groups, descending: true) { it[METRIC_SOURCES.fetch(column)] }
|
|
44
|
+
end
|
|
45
|
+
self
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def to_rows
|
|
49
|
+
[keys.map(&:to_s) + METRICS.map(&:to_s)] +
|
|
50
|
+
@groups.map do |group|
|
|
51
|
+
keys.map { |key| display_key(key, group[key]) } + [
|
|
52
|
+
"#{group[:solved]}/#{group[:attempts]}",
|
|
53
|
+
time(group[:duration]),
|
|
54
|
+
cost(group[:cost_usd]),
|
|
55
|
+
mean_display(group[:steps], 1),
|
|
56
|
+
mean_display(group[:tokens], 0)
|
|
57
|
+
]
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def to_csv
|
|
62
|
+
columns = keys + %i[solved attempts duration cost_usd steps tokens]
|
|
63
|
+
CSV.generate do |csv|
|
|
64
|
+
csv << columns
|
|
65
|
+
@groups.each { |group| csv << columns.map { group[it] } }
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def summary = report.summary
|
|
70
|
+
|
|
71
|
+
def summary_lines = report.summary_lines
|
|
72
|
+
|
|
73
|
+
private
|
|
74
|
+
|
|
75
|
+
# Attempts count every run; means and the median skip runs that never
|
|
76
|
+
# measured the value, so one invalid trial cannot zero out a cell.
|
|
77
|
+
def build(values, group)
|
|
78
|
+
keys.zip(values).to_h.merge(
|
|
79
|
+
solved: Report.tally(group)[:solved],
|
|
80
|
+
attempts: group.size,
|
|
81
|
+
duration: median(group.filter_map { it[:duration] }),
|
|
82
|
+
cost_usd: mean(group.filter_map { it[:cost_usd] }),
|
|
83
|
+
steps: mean(group.filter_map { it[:steps] }),
|
|
84
|
+
tokens: mean(group.filter_map { it[:tokens] })
|
|
85
|
+
)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def mean(values) = values.empty? ? nil : values.sum(0.0) / values.size
|
|
89
|
+
|
|
90
|
+
def median(values)
|
|
91
|
+
return nil if values.empty?
|
|
92
|
+
|
|
93
|
+
sorted = values.sort
|
|
94
|
+
mid = sorted.size / 2
|
|
95
|
+
sorted.size.odd? ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2.0
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def display_key(key, value)
|
|
99
|
+
return "-" if value.nil?
|
|
100
|
+
|
|
101
|
+
key == :model ? Report.short_model(value) : value.to_s
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def time(sec)
|
|
105
|
+
return "-" if sec.nil?
|
|
106
|
+
|
|
107
|
+
minutes, seconds = sec.round.divmod(60)
|
|
108
|
+
minutes.positive? ? "#{minutes}m #{seconds}s" : "#{seconds}s"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def cost(value) = value.nil? ? "-" : "$#{format("%g", value.round(4))}"
|
|
112
|
+
|
|
113
|
+
def mean_display(value, digits) = value.nil? ? "-" : format("%g", value.round(digits))
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "csv"
|
|
4
|
+
|
|
5
|
+
module Lemans
|
|
6
|
+
class CLI < Thor
|
|
7
|
+
# Renders stored results as a table or CSV. The store is the source of
|
|
8
|
+
# truth; rows are plain hashes derived from Result records.
|
|
9
|
+
class Report
|
|
10
|
+
COLUMNS = %i[task agent model reward outcome scored cost_usd steps tokens duration started_at trial tags
|
|
11
|
+
detail].freeze
|
|
12
|
+
TABLE_COLUMNS = %i[task agent model reward outcome cost_usd steps tokens duration trial].freeze
|
|
13
|
+
NUMERIC_COLUMNS = %i[reward cost_usd steps tokens duration].freeze
|
|
14
|
+
|
|
15
|
+
attr_reader :rows, :unreadable
|
|
16
|
+
|
|
17
|
+
class << self
|
|
18
|
+
def load(store, tags: nil, names: nil)
|
|
19
|
+
rows = store.query(task: names, tags:).map { row_from(it) }
|
|
20
|
+
new(rows.sort_by { [it[:task].to_s, it[:started_at].to_s, it[:trial].to_s] },
|
|
21
|
+
unreadable: store.unreadable.size)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def row_from(result)
|
|
25
|
+
usage = result.usage
|
|
26
|
+
{
|
|
27
|
+
task: result.task,
|
|
28
|
+
agent: result.agent,
|
|
29
|
+
model: result.model,
|
|
30
|
+
reward: result.reward,
|
|
31
|
+
outcome: result.status,
|
|
32
|
+
scored: result.scored?,
|
|
33
|
+
detail: result.detail,
|
|
34
|
+
cost_usd: usage&.cost_usd,
|
|
35
|
+
steps: usage&.steps,
|
|
36
|
+
# Tokens the model actually consumed and produced; cache reads stay
|
|
37
|
+
# out, matching how providers meter a run.
|
|
38
|
+
tokens: usage && (usage.input_tokens.to_i + usage.output_tokens.to_i),
|
|
39
|
+
duration: result.duration,
|
|
40
|
+
started_at: result.started_at&.iso8601,
|
|
41
|
+
trial: result.id,
|
|
42
|
+
tags: result.tags.map(&:to_s)
|
|
43
|
+
}
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# A bench may name no model at all (nop, oracle); the summary needs a
|
|
47
|
+
# label, not a nil for ljust to crash on.
|
|
48
|
+
def short_model(model) = model.to_s.split("/").last || "(default)"
|
|
49
|
+
|
|
50
|
+
# One definition of the numbers everyone quotes — total, scored,
|
|
51
|
+
# invalid, solved — so the views can never drift apart.
|
|
52
|
+
def tally(rows)
|
|
53
|
+
scored = rows.count { it[:scored] }
|
|
54
|
+
{
|
|
55
|
+
total: rows.size,
|
|
56
|
+
scored:,
|
|
57
|
+
invalid: rows.size - scored,
|
|
58
|
+
solved: rows.count { it[:reward].to_f >= 1.0 }
|
|
59
|
+
}
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# One sorting rule for every view: validate the column name and keep
|
|
63
|
+
# rows that never measured the value at the bottom.
|
|
64
|
+
def sort_column(name, allowed:)
|
|
65
|
+
column = name.to_s.to_sym
|
|
66
|
+
return column if allowed.include?(column)
|
|
67
|
+
|
|
68
|
+
raise ConfigError, "--sort: unknown column #{name.inspect} (try #{allowed.join(", ")})"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def sort_rows(rows, descending: false)
|
|
72
|
+
keyed = rows.map { [yield(it), it] }
|
|
73
|
+
present, missing = keyed.partition { |value, _| value }
|
|
74
|
+
sorted = present.sort_by { |value, _| value }
|
|
75
|
+
sorted.reverse! if descending
|
|
76
|
+
(sorted + missing).map(&:last)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def initialize(rows, unreadable: 0)
|
|
81
|
+
@rows = rows
|
|
82
|
+
@unreadable = unreadable
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# A store holding only unreadable results is not empty: the report's
|
|
86
|
+
# job is to say so.
|
|
87
|
+
def empty? = rows.empty? && unreadable.zero?
|
|
88
|
+
|
|
89
|
+
# Numbers rank best-first the way a leaderboard reads; names sort A-Z.
|
|
90
|
+
# Trials that never measured the column sink to the bottom either way.
|
|
91
|
+
def order_by!(column)
|
|
92
|
+
column = self.class.sort_column(column, allowed: TABLE_COLUMNS)
|
|
93
|
+
@rows = self.class.sort_rows(rows, descending: NUMERIC_COLUMNS.include?(column)) { it[column] }
|
|
94
|
+
self
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def summary
|
|
98
|
+
self.class.tally(rows).merge(cost_usd: rows.sum { it[:cost_usd].to_f })
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def to_rows
|
|
102
|
+
[TABLE_COLUMNS.map(&:to_s)] +
|
|
103
|
+
rows.map do |row|
|
|
104
|
+
TABLE_COLUMNS.map do |column|
|
|
105
|
+
display(column == :model ? short_model(row[:model]) : row[column])
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def summary_lines
|
|
111
|
+
per_model = rows.group_by { short_model(it[:model]) }
|
|
112
|
+
lines =
|
|
113
|
+
if per_model.size > 1
|
|
114
|
+
width = per_model.keys.map(&:length).max
|
|
115
|
+
per_model.map { |model, group| "#{model.ljust(width)} #{stats(group)}" } +
|
|
116
|
+
["#{"total".ljust(width)} #{stats(rows)}"]
|
|
117
|
+
else
|
|
118
|
+
[stats(rows)]
|
|
119
|
+
end
|
|
120
|
+
lines[-1] = "#{lines[-1]} · #{unreadable} unreadable result(s) skipped" if unreadable.positive?
|
|
121
|
+
lines
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def to_csv
|
|
125
|
+
CSV.generate do |csv|
|
|
126
|
+
csv << COLUMNS
|
|
127
|
+
rows.each do |row|
|
|
128
|
+
csv << COLUMNS.map { |column| column == :tags ? Array(row[:tags]).join(" ") : row[column] }
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
private
|
|
134
|
+
|
|
135
|
+
# The rank divides solved by scored, not total: invalid trials measured nothing.
|
|
136
|
+
def stats(group)
|
|
137
|
+
totals = self.class.tally(group).merge(cost_usd: group.sum { it[:cost_usd].to_f })
|
|
138
|
+
rank = totals[:scored].positive? ? " (#{(100.0 * totals[:solved] / totals[:scored]).round}%)" : ""
|
|
139
|
+
"#{totals[:total]} trials: #{totals[:scored]} scored, #{totals[:invalid]} invalid, " \
|
|
140
|
+
"#{totals[:solved]} solved#{rank} · $#{format("%.4f", totals[:cost_usd])}#{pass_at_k(group)}"
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def pass_at_k(group)
|
|
144
|
+
cells = group.select { it[:scored] }.group_by { [it[:model], it[:task]] }.values
|
|
145
|
+
sizes = cells.map(&:size).uniq
|
|
146
|
+
return "" unless sizes.any? { it > 1 }
|
|
147
|
+
|
|
148
|
+
solved = cells.count { |trials| trials.any? { it[:reward].to_f >= 1.0 } }
|
|
149
|
+
label = sizes.size == 1 ? "pass@#{sizes.first}" : "pass@k"
|
|
150
|
+
" · #{label} #{solved}/#{cells.size} tasks (#{(100.0 * solved / cells.size).round}%)"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def short_model(model) = self.class.short_model(model)
|
|
154
|
+
|
|
155
|
+
def display(value)
|
|
156
|
+
case value
|
|
157
|
+
when nil then "-"
|
|
158
|
+
when Float then format("%g", value.round(4))
|
|
159
|
+
else value.to_s
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
data/lib/lemans/cli.rb
CHANGED
|
@@ -19,12 +19,14 @@ module Lemans
|
|
|
19
19
|
|
|
20
20
|
desc "tasks", "List the tasks in a bench"
|
|
21
21
|
option :bench, default: ".", desc: "Directory holding bench.yml"
|
|
22
|
-
option :tag, desc: "Only tasks carrying this tag"
|
|
22
|
+
option :tag, desc: "Only tasks carrying this tag", repeatable: true
|
|
23
23
|
def tasks
|
|
24
|
-
|
|
24
|
+
config = Config.load_file(options[:bench])
|
|
25
|
+
tasks = filter_tasks(config.tasks, tags: options[:tag])
|
|
26
|
+
|
|
25
27
|
print_table(
|
|
26
28
|
[%w[task difficulty tags description]] +
|
|
27
|
-
|
|
29
|
+
tasks.map { [it.name, it.difficulty, it.tags.join(","), it.description] }
|
|
28
30
|
)
|
|
29
31
|
rescue ConfigError => e
|
|
30
32
|
raise Thor::Error, "lemans: #{e.message}"
|
|
@@ -34,12 +36,12 @@ module Lemans
|
|
|
34
36
|
desc "run", "Run tasks and verify them"
|
|
35
37
|
option :bench, default: ".", desc: "Directory holding bench.yml"
|
|
36
38
|
option :task, desc: "Run task(s) by name", repeatable: true
|
|
37
|
-
option :tag, desc: "Run every task carrying this tag"
|
|
38
|
-
option :agent, desc: "Override the agent from bench.yml (miniswen, oracle, nop)"
|
|
39
|
+
option :tag, desc: "Run every task carrying this tag(s)", repeatable: true
|
|
40
|
+
option :agent, desc: "Override the agent from bench.yml (miniswen, miniswen-installed, oracle, nop)"
|
|
39
41
|
option :model, desc: "Override the model(s) from bench.yml", repeatable: true
|
|
40
42
|
option :attempts, type: :numeric, default: 1, aliases: "-k", desc: "Trials per task"
|
|
41
43
|
option :concurrency, type: :numeric, default: 4, aliases: "-c", desc: "Trials in flight at once"
|
|
42
|
-
option :runs_dir, default: "runs", desc: "Where to write run directories"
|
|
44
|
+
option :runs_dir, default: "./runs", desc: "Where to write run directories"
|
|
43
45
|
option :backend, default: "daytona", enum: Environments::BACKENDS.keys, desc: "Sandbox backend"
|
|
44
46
|
option :resume, type: :boolean, default: false, desc: "Skip trials that already have a result"
|
|
45
47
|
def run_bench
|
|
@@ -47,74 +49,68 @@ module Lemans
|
|
|
47
49
|
# front, so every trial prices completions against the same revision.
|
|
48
50
|
Miniswen.refresh_registry!
|
|
49
51
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
attempts: Integer(options[:attempts]),
|
|
61
|
-
concurrency: Integer(options[:concurrency]),
|
|
62
|
-
resume: options[:resume]
|
|
63
|
-
)
|
|
64
|
-
if run.total.zero?
|
|
52
|
+
config = Config.load_file(options[:bench])
|
|
53
|
+
config.load_options(**options.transform_keys(&:to_sym))
|
|
54
|
+
|
|
55
|
+
tasks = filter_tasks(config.tasks, tags: options[:tag], name: options[:task])
|
|
56
|
+
|
|
57
|
+
store = Stores::FS.new(options[:runs_dir])
|
|
58
|
+
|
|
59
|
+
runner = Runner.new(config, tasks, store:, resume: options[:resume])
|
|
60
|
+
|
|
61
|
+
if runner.resuming? && runner.attempts.empty?
|
|
65
62
|
say_status :resume, "nothing to run — every task × model already has " \
|
|
66
|
-
"#{
|
|
63
|
+
"#{config.attempts} scored attempt(s)", :green
|
|
64
|
+
|
|
65
|
+
return
|
|
67
66
|
end
|
|
68
67
|
|
|
69
|
-
|
|
70
|
-
progress =
|
|
68
|
+
reporter =
|
|
71
69
|
if interactive?
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
attempts: Integer(options[:attempts]), total: run.total)
|
|
70
|
+
BoardReporter.new(tasks: tasks.map(&:name), models: config.models,
|
|
71
|
+
attempts: config.attempts, total: runner.attempts.size)
|
|
75
72
|
else
|
|
76
|
-
ProgressReporter.new(shell
|
|
73
|
+
ProgressReporter.new(shell:, tasks: tasks.map(&:name))
|
|
77
74
|
end
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
75
|
+
|
|
76
|
+
reporter.start
|
|
77
|
+
|
|
78
|
+
summary = runner.run(reporter)
|
|
81
79
|
|
|
82
80
|
say ""
|
|
83
81
|
say_status :report, "collecting results from #{options[:runs_dir]}", :cyan
|
|
84
|
-
print_report
|
|
85
|
-
|
|
86
|
-
exit
|
|
82
|
+
print_report Report.load(store)
|
|
83
|
+
|
|
84
|
+
exit 130 if summary.status == :interrupted
|
|
85
|
+
exit 1 if summary.status == :invalid
|
|
87
86
|
rescue ConfigError => e
|
|
88
87
|
raise Thor::Error, "lemans: #{e.message}"
|
|
89
88
|
rescue Interrupt
|
|
90
89
|
say ""
|
|
91
90
|
exit 130
|
|
92
91
|
ensure
|
|
93
|
-
|
|
92
|
+
reporter&.stop
|
|
94
93
|
end
|
|
95
94
|
|
|
96
95
|
desc "clobber", "Delete run results"
|
|
97
|
-
option :runs_dir, default: "runs", desc: "Directory holding run directories"
|
|
98
|
-
option :task,
|
|
96
|
+
option :runs_dir, default: "./runs", desc: "Directory holding run directories"
|
|
97
|
+
option :task, desc: "Only these tasks' runs", repeatable: true
|
|
99
98
|
option :ttl, desc: "Only runs older than this (10m, 2h, 1d)"
|
|
100
99
|
option :invalid, type: :boolean, default: false, desc: "Only runs that measured nothing (invalid or unreadable)"
|
|
101
100
|
option :force, type: :boolean, default: false, aliases: "-f", desc: "Delete without asking"
|
|
102
101
|
def clobber
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
ttl_sec: Units.seconds(options[:ttl], field: "--ttl"),
|
|
107
|
-
invalid: options[:invalid]
|
|
108
|
-
)
|
|
102
|
+
store = Stores::FS.new(options[:runs_dir])
|
|
103
|
+
clobber = Clobber.new(store, tasks: options[:task], ttl: options[:ttl], invalid: options[:invalid])
|
|
104
|
+
|
|
109
105
|
doomed = clobber.matches
|
|
110
106
|
return say "lemans: nothing to clobber under #{options[:runs_dir]}" if doomed.empty?
|
|
111
107
|
|
|
112
108
|
unless options[:force]
|
|
113
|
-
doomed.each { say
|
|
109
|
+
doomed.each { say it.id }
|
|
114
110
|
return say "lemans: nothing deleted" unless yes?("Delete #{doomed.size} run(s) under #{options[:runs_dir]}? [y/N]")
|
|
115
111
|
end
|
|
116
112
|
|
|
117
|
-
removed = clobber.
|
|
113
|
+
removed = clobber.execute!
|
|
118
114
|
say "deleted #{removed.size} run(s)"
|
|
119
115
|
rescue ConfigError => e
|
|
120
116
|
raise Thor::Error, "lemans: #{e.message}"
|
|
@@ -122,19 +118,18 @@ module Lemans
|
|
|
122
118
|
|
|
123
119
|
desc "report", "Summarize run results as a table or CSV"
|
|
124
120
|
option :runs_dir, default: "runs", desc: "Directory holding run directories"
|
|
125
|
-
option :tag, desc: "Only runs whose result carries this tag"
|
|
121
|
+
option :tag, desc: "Only runs whose result carries this tag", repeatable: true
|
|
122
|
+
option :task, desc: "Only these tasks' runs", repeatable: true
|
|
126
123
|
option :format, default: "table", enum: %w[table csv], desc: "Output format"
|
|
127
124
|
option :aggregate, aliases: "-A", banner: "COLUMNS", lazy_default: "task-model",
|
|
128
125
|
desc: "Group results by 1-3 dash-joined columns (task, agent, model)"
|
|
129
126
|
option :sort, aliases: "-S", banner: "COLUMN", desc: "Sort by a column"
|
|
130
127
|
def report
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
raise Thor::Error, "lemans: no results#{tagged} under #{options[:runs_dir]}"
|
|
135
|
-
end
|
|
128
|
+
store = Stores::FS.new(options[:runs_dir])
|
|
129
|
+
results = Report.load(store, tags: options[:tag], names: options[:task])
|
|
130
|
+
raise Thor::Error, "lemans: no matching results found" if results.empty?
|
|
136
131
|
|
|
137
|
-
results =
|
|
132
|
+
results = Report::Aggregate.new(results, keys: Report::Aggregate.keys(options[:aggregate])) if options[:aggregate]
|
|
138
133
|
results.order_by!(options[:sort]) if options[:sort]
|
|
139
134
|
options[:format] == "csv" ? say(results.to_csv) : print_report(results)
|
|
140
135
|
rescue ConfigError => e
|
|
@@ -143,24 +138,24 @@ module Lemans
|
|
|
143
138
|
|
|
144
139
|
private
|
|
145
140
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
tasks
|
|
141
|
+
def filter_tasks(tasks, tags: nil, name: nil)
|
|
142
|
+
tasks = tasks.dup
|
|
143
|
+
|
|
144
|
+
name = Array(name) if name
|
|
145
|
+
tags = Array(tags) if tags
|
|
146
|
+
|
|
147
|
+
tasks.select! { name.include?(it.name) } if name
|
|
148
|
+
tasks.select! { tags.intersect?(it.tags) } if tags
|
|
149
|
+
|
|
153
150
|
return tasks unless tasks.empty?
|
|
154
151
|
|
|
155
|
-
|
|
156
|
-
options[:tag] && "task tagged #{options[:tag].inspect}"].compact.join(" and no ")
|
|
157
|
-
raise Thor::Error, "lemans: no #{wanted.empty? ? "tasks in #{options[:bench]}" : wanted}"
|
|
152
|
+
raise Thor::Error, "lemans: no matching tasks"
|
|
158
153
|
end
|
|
159
154
|
|
|
160
155
|
def print_report(report)
|
|
161
156
|
print_table report.to_rows
|
|
162
157
|
color = report.summary[:invalid].positive? ? :red : nil
|
|
163
|
-
report.summary_lines.each { say
|
|
158
|
+
report.summary_lines.each { say it, color }
|
|
164
159
|
end
|
|
165
160
|
|
|
166
161
|
def interactive?
|