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
@@ -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(environment, task:) # rubocop:disable Lint/UnusedMethodArgument
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(environment, task:, logs_dir:)
34
- run = environment.exec(command_for(task), timeout: profile.timeout_sec + EXEC_SLACK_SEC,
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
- environment.download(RESULTS_PATH, local)
40
- ::Miniswen::Agent::Result.from_h(JSON.parse(local.read))
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.timeout_sec.to_i,
61
- "--exec-timeout", profile.exec_timeout_sec.to_i]
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
@@ -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 < Base
7
+ class Nop < Agent
8
8
  NAME = "nop"
9
9
 
10
- def call(_environment, task:, logs_dir:) # rubocop:disable Lint/UnusedMethodArgument
11
- Result.new(outcome: Results::Outcome.new(:completed), usage: Results::Usage.zero, trajectory: nil)
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
@@ -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 < Base
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 call(environment, task:, logs_dir:) # rubocop:disable Lint/UnusedMethodArgument
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
- result = environment.exec(command_for(task), timeout: timeout_sec)
20
+ outcome = environment.exec(command_for(task), timeout:)
21
21
 
22
- unless result.success?
22
+ unless outcome.success?
23
23
  raise InfrastructureError,
24
- "#{task.name}: the solution itself failed (exit #{result.exit_code}): " \
25
- "#{result.output.to_s[0, 500]}"
24
+ "#{task.name}: the solution itself failed (exit #{outcome.exit_code}): " \
25
+ "#{outcome.output.to_s[0, 500]}"
26
26
  end
27
27
 
28
- Result.new(outcome: Results::Outcome.new(:completed), usage: Results::Usage.zero, trajectory: nil)
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.bench.environment.workdir)} && git apply --binary --whitespace=nowarn #{REMOTE_DIR}/#{PATCH}"
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(_1) }
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[:index] - 1] = :running
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[:index] - 1] = 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 — waiting for #{data[:in_flight]} in-flight trial(s), ^C again to abandon#{RESET}"
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(data)
75
- return if data[:scored] || data[:detail].nil?
74
+ def announce_error(result)
75
+ return if result.scored? || result.detail.nil?
76
76
 
77
77
  erase
78
- @out.puts "\e[2K#{RED}#{data[:task]}: #{data[:outcome]} — " \
79
- "#{data[:detail].to_s.lines.first.to_s.strip[0, MAX_DETAIL_CHARS]}#{RESET}"
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(data) = @cells[[data[:task], short(data[:model])]]
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(_1, task_width, cell_width) }
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#{_1}" }.join("\n")
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 { _1.ljust([_1.length, cell_width].max) }.join(" ")}#{RESET}"
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(_1) }.join + (" " * [pad, 0].max)
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[:scored] then "#{RED}!#{RESET}"
128
- elsif state[:reward].to_f >= 1.0 then "#{GREEN}✔#{RESET}"
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 outcome names get a
8
- # short verb here and keep their full name in the table and result.json.
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:, task_width:)
23
+ def initialize(shell:, tasks:)
24
24
  @shell = shell
25
- @task_width = 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(data)
45
- attempt = "attempt #{data[:index].to_s.rjust(data[:attempts].to_s.length)}/#{data[:attempts]}"
46
- @shell.say_status :run, "#{data[:task].to_s.ljust(@task_width)} #{attempt} #{data[:trial]}", :blue
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(data)
50
- status = data[:scored] ? "reward=#{data[:reward].inspect}" : data[:outcome].to_s
51
- @shell.say_status STATUS_VERBS.fetch(data[:outcome].to_sym, data[:outcome].to_sym),
52
- "#{data[:task].to_s.ljust(@task_width)} #{status.ljust(12)} #{data[:duration_sec]}s",
53
- color(data)
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(data[:detail]), :red unless data[:scored] || data[:detail].nil?
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(data)
61
- return :red unless data[:scored]
60
+ def color(result)
61
+ return :red unless result.scored?
62
62
 
63
- data[:reward].to_f >= 1.0 ? :green : :yellow
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