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
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
4
|
require "shellwords"
|
|
5
|
+
require "tempfile"
|
|
5
6
|
|
|
6
7
|
module Lemans
|
|
7
8
|
module Agents
|
|
@@ -12,13 +13,12 @@ module Lemans
|
|
|
12
13
|
class MiniswenInstalled < Miniswen
|
|
13
14
|
NAME = "miniswen-installed"
|
|
14
15
|
RESULTS_PATH = "/tmp/lemans-miniswen.result.json"
|
|
15
|
-
RESULT_FILENAME = "miniswen.result.json"
|
|
16
16
|
INSTALL_TIMEOUT_SEC = 300
|
|
17
17
|
# The CLI enforces max-time itself; the slack only covers process
|
|
18
18
|
# startup, so the results file exists before the outer exec expires.
|
|
19
19
|
EXEC_SLACK_SEC = 60
|
|
20
20
|
|
|
21
|
-
def install(
|
|
21
|
+
def install(_task, environment)
|
|
22
22
|
environment.exec!(
|
|
23
23
|
"command -v miniswen >/dev/null 2>&1 || gem install miniswen -v #{::Miniswen::VERSION} --no-document",
|
|
24
24
|
timeout: INSTALL_TIMEOUT_SEC
|
|
@@ -30,14 +30,16 @@ module Lemans
|
|
|
30
30
|
|
|
31
31
|
# An in-sandbox run self-reports: everything but the verifier's reward
|
|
32
32
|
# comes from a file the sandbox wrote.
|
|
33
|
-
def obtain_result(
|
|
34
|
-
run = environment.exec(command_for(task), timeout: profile.
|
|
33
|
+
def obtain_result(task, environment)
|
|
34
|
+
run = environment.exec(command_for(task), timeout: profile.timeout + EXEC_SLACK_SEC,
|
|
35
35
|
env: provider_env(environment))
|
|
36
36
|
|
|
37
|
-
local = logs_dir.join(RESULT_FILENAME)
|
|
38
37
|
begin
|
|
39
|
-
|
|
40
|
-
|
|
38
|
+
Tempfile.create(%w[miniswen .result.json]) do |file|
|
|
39
|
+
environment.download(RESULTS_PATH, file.path)
|
|
40
|
+
@raw_result = File.read(file.path)
|
|
41
|
+
::Miniswen::Agent::Result.from_h(JSON.parse(@raw_result))
|
|
42
|
+
end
|
|
41
43
|
rescue StandardError => e
|
|
42
44
|
raise InfrastructureError,
|
|
43
45
|
"miniswen-installed: no usable result file (exit #{run.exit_code}, #{e.message}): " \
|
|
@@ -45,6 +47,8 @@ module Lemans
|
|
|
45
47
|
end
|
|
46
48
|
end
|
|
47
49
|
|
|
50
|
+
attr_reader :raw_result
|
|
51
|
+
|
|
48
52
|
# A missing credential fails the run before the sandbox executes
|
|
49
53
|
# anything: it is the operator's configuration to fix, not a trial result.
|
|
50
54
|
def provider_env(environment)
|
|
@@ -57,8 +61,8 @@ module Lemans
|
|
|
57
61
|
argv = ["miniswen", "-q", "--no-refresh-registry",
|
|
58
62
|
"-m", model.to_s, "-p", task.instruction,
|
|
59
63
|
"--results-path", RESULTS_PATH,
|
|
60
|
-
"--max-steps", profile.step_limit, "--max-time", profile.
|
|
61
|
-
"--exec-timeout", profile.
|
|
64
|
+
"--max-steps", profile.step_limit, "--max-time", profile.timeout.to_i,
|
|
65
|
+
"--exec-timeout", profile.exec_timeout.to_i]
|
|
62
66
|
argv += ["--max-cost", profile.cost_limit.to_i] if profile.cost_limit
|
|
63
67
|
argv.map { Shellwords.escape(_1.to_s) }.join(" ")
|
|
64
68
|
end
|
data/lib/lemans/agents/nop.rb
CHANGED
|
@@ -4,11 +4,11 @@ module Lemans
|
|
|
4
4
|
module Agents
|
|
5
5
|
# Does nothing, on purpose: how a task proves its verifier rejects an
|
|
6
6
|
# untouched tree.
|
|
7
|
-
class Nop <
|
|
7
|
+
class Nop < Agent
|
|
8
8
|
NAME = "nop"
|
|
9
9
|
|
|
10
|
-
def
|
|
11
|
-
|
|
10
|
+
def run(_task, _environment)
|
|
11
|
+
Response.new(outcome: Result::Outcome.new(:completed), usage: Result::Usage.zero)
|
|
12
12
|
end
|
|
13
13
|
end
|
|
14
14
|
end
|
data/lib/lemans/agents/oracle.rb
CHANGED
|
@@ -6,26 +6,26 @@ module Lemans
|
|
|
6
6
|
module Agents
|
|
7
7
|
# Runs the task's own solution instead of a model. A task whose oracle
|
|
8
8
|
# does not score full marks is broken, not hard.
|
|
9
|
-
class Oracle <
|
|
9
|
+
class Oracle < Agent
|
|
10
10
|
NAME = "oracle"
|
|
11
11
|
REMOTE_DIR = "/solution"
|
|
12
12
|
SOLVE = "solve"
|
|
13
13
|
ENTRYPOINT = "solve.sh"
|
|
14
14
|
PATCH = "solution.patch"
|
|
15
15
|
|
|
16
|
-
def
|
|
16
|
+
def run(task, environment)
|
|
17
17
|
raise ConfigError, "#{task.name}: no solution/ to run — the oracle has nothing to prove" unless task.solution?
|
|
18
18
|
|
|
19
19
|
upload_solution(environment, task)
|
|
20
|
-
|
|
20
|
+
outcome = environment.exec(command_for(task), timeout:)
|
|
21
21
|
|
|
22
|
-
unless
|
|
22
|
+
unless outcome.success?
|
|
23
23
|
raise InfrastructureError,
|
|
24
|
-
"#{task.name}: the solution itself failed (exit #{
|
|
25
|
-
"#{
|
|
24
|
+
"#{task.name}: the solution itself failed (exit #{outcome.exit_code}): " \
|
|
25
|
+
"#{outcome.output.to_s[0, 500]}"
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
-
|
|
28
|
+
Response.new(outcome: Result::Outcome.new(:completed), usage: Result::Usage.zero)
|
|
29
29
|
end
|
|
30
30
|
|
|
31
31
|
private
|
|
@@ -40,7 +40,7 @@ module Lemans
|
|
|
40
40
|
|
|
41
41
|
raise ConfigError, "#{task.name}: the solution ships neither #{SOLVE}, #{ENTRYPOINT} nor #{PATCH}" unless shipped.include?(PATCH)
|
|
42
42
|
|
|
43
|
-
"cd #{Shellwords.escape(task.
|
|
43
|
+
"cd #{Shellwords.escape(task.environment.workdir)} && git apply --binary --whitespace=nowarn #{REMOTE_DIR}/#{PATCH}"
|
|
44
44
|
end
|
|
45
45
|
|
|
46
46
|
def upload_solution(environment, task)
|
|
@@ -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
|