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.
Files changed (69) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +30 -10
  3. data/exe/lemans-remote +94 -29
  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
@@ -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
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
- bench = Bench.load(options[:bench])
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
- select_tasks(bench).map { [_1.name, _1.difficulty, _1.tags.join(","), _1.description] }
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
- bench = Bench.load(options[:bench])
51
- tasks = select_tasks(bench)
52
-
53
- run = Run.new(
54
- bench: bench,
55
- tasks: tasks,
56
- agent_name: options[:agent] || bench.agent.name,
57
- model: options[:model],
58
- backend: options[:backend],
59
- runs_dir: options[:runs_dir],
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
- "#{options[:attempts]} scored attempt(s)", :green
63
+ "#{config.attempts} scored attempt(s)", :green
64
+
65
+ return
67
66
  end
68
67
 
69
- # A tty gets the live board; a pipe gets plain streamed lines.
70
- progress =
68
+ reporter =
71
69
  if interactive?
72
- models = options[:model] || (bench.agent.models.empty? ? [bench.agent.model] : bench.agent.models)
73
- BoardReporter.new(tasks: tasks.map(&:name), models: models,
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: shell, task_width: tasks.map { _1.name.length }.max)
73
+ ProgressReporter.new(shell:, tasks: tasks.map(&:name))
77
74
  end
78
- progress.start
79
- summary = run.call { |event, data| progress.record(event, data) }
80
- progress.stop
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 Results::Report.load(options[:runs_dir])
85
- exit 130 if summary[:interrupted]
86
- exit 1 if summary[:invalid].positive?
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
- progress&.stop
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, type: :array, desc: "Only these tasks' runs (space-separated)"
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
- clobber = Clobber.new(
104
- runs_dir: options[:runs_dir],
105
- tasks: options[:task],
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 _1.to_s }
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.call
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
- results = Results::Report.load(options[:runs_dir], tag: options[:tag])
132
- if results.empty?
133
- tagged = options[:tag] ? " tagged #{options[:tag].inspect}" : ""
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 = Results::Aggregate.new(results, keys: Results::Aggregate.keys(options[:aggregate])) if options[:aggregate]
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
- # The one task filter for every command that walks a bench: an empty
147
- # selection is an error, because running or listing nothing is never
148
- # what a named task or tag meant.
149
- def select_tasks(bench)
150
- tasks = bench.tasks
151
- tasks = tasks.select { options[:task].include?(_1.name) } if options[:task]
152
- tasks = tasks.select { _1.tags.include?(options[:tag]) } if options[:tag]
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
- wanted = [options[:task] && "task named #{options[:task].inspect}",
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 _1, color }
158
+ report.summary_lines.each { say it, color }
164
159
  end
165
160
 
166
161
  def interactive?