lemans 0.0.0.pre → 0.2.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/CHANGELOG.md +9 -0
- data/LICENSE.txt +21 -0
- data/README.md +228 -0
- data/exe/lemans +17 -0
- data/exe/lemans-remote +984 -0
- data/lib/lemans/agents/base.rb +30 -0
- data/lib/lemans/agents/miniswen.rb +119 -0
- data/lib/lemans/agents/miniswen_installed.rb +67 -0
- data/lib/lemans/agents/nop.rb +15 -0
- data/lib/lemans/agents/oracle.rb +53 -0
- data/lib/lemans/agents.rb +21 -0
- data/lib/lemans/bench.rb +280 -0
- data/lib/lemans/cli/board_reporter.rb +135 -0
- data/lib/lemans/cli/progress_reporter.rb +67 -0
- data/lib/lemans/cli.rb +181 -0
- data/lib/lemans/clobber.rb +79 -0
- data/lib/lemans/environments/base.rb +55 -0
- data/lib/lemans/environments/daytona/retries.rb +49 -0
- data/lib/lemans/environments/daytona/sdk_tweaks.rb +50 -0
- data/lib/lemans/environments/daytona/shell.rb +142 -0
- data/lib/lemans/environments/daytona/snapshot_store.rb +163 -0
- data/lib/lemans/environments/daytona.rb +175 -0
- data/lib/lemans/environments.rb +16 -0
- data/lib/lemans/network_policy.rb +66 -0
- data/lib/lemans/patch.rb +70 -0
- data/lib/lemans/restore_paths.rb +21 -0
- data/lib/lemans/results/aggregate.rb +114 -0
- data/lib/lemans/results/cost_source.rb +13 -0
- data/lib/lemans/results/outcome.rb +36 -0
- data/lib/lemans/results/report.rb +149 -0
- data/lib/lemans/results/sorting.rb +24 -0
- data/lib/lemans/results/tally.rb +19 -0
- data/lib/lemans/results/usage.rb +24 -0
- data/lib/lemans/run.rb +152 -0
- data/lib/lemans/setup.rb +59 -0
- data/lib/lemans/setup_files.rb +36 -0
- data/lib/lemans/snapshot.rb +55 -0
- data/lib/lemans/task.rb +207 -0
- data/lib/lemans/tree_digest.rb +24 -0
- data/lib/lemans/trial.rb +187 -0
- data/lib/lemans/units.rb +44 -0
- data/lib/lemans/verifier/assets/eport-lemans.rb +36 -0
- data/lib/lemans/verifier/assets/lemans_minitest_reporter.rb +61 -0
- data/lib/lemans/verifier.rb +199 -0
- data/lib/lemans/version.rb +5 -0
- data/lib/lemans.rb +29 -0
- data/lib/miniswen/agent.rb +678 -0
- data/lib/miniswen/cli.rb +224 -0
- data/lib/miniswen/environment.rb +14 -0
- data/lib/miniswen/local.rb +42 -0
- data/lib/miniswen/ruby_llm.rb +42 -0
- data/lib/miniswen/testing.rb +134 -0
- data/lib/miniswen/trajectory.rb +110 -0
- data/lib/miniswen/version.rb +5 -0
- data/lib/miniswen.rb +48 -0
- metadata +161 -7
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "csv"
|
|
4
|
+
|
|
5
|
+
module Lemans
|
|
6
|
+
module Results
|
|
7
|
+
# Rolls trials up the way a leaderboard quotes them: solved out of
|
|
8
|
+
# attempts, median time, mean spend per run. Groups by any 1-3 of
|
|
9
|
+
# task, agent, model — "task-model" reads as two columns.
|
|
10
|
+
class Aggregate
|
|
11
|
+
KEYS = %i[task agent model].freeze
|
|
12
|
+
METRICS = %i[score time cost steps tokens].freeze
|
|
13
|
+
METRIC_SOURCES = { time: :duration_sec, cost: :cost_usd, steps: :steps, tokens: :tokens }.freeze
|
|
14
|
+
|
|
15
|
+
attr_reader :report, :keys
|
|
16
|
+
|
|
17
|
+
def self.keys(spec)
|
|
18
|
+
keys = spec.to_s.split("-").map(&:to_sym)
|
|
19
|
+
return keys if keys.size.between?(1, 3) && keys.uniq == keys && (keys - KEYS).empty?
|
|
20
|
+
|
|
21
|
+
raise ConfigError, "--aggregate: expected 1-3 of #{KEYS.join(", ")} joined by dashes (got #{spec.inspect})"
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def initialize(report, keys:)
|
|
25
|
+
@report = report
|
|
26
|
+
@keys = keys
|
|
27
|
+
@groups = report.rows
|
|
28
|
+
.group_by { |row| keys.map { row[_1] } }
|
|
29
|
+
.map { |values, group| build(values, group) }
|
|
30
|
+
.sort_by { |group| keys.map { group[_1].to_s } }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def order_by!(column)
|
|
34
|
+
column = Sorting.column(column, allowed: keys + METRICS)
|
|
35
|
+
@groups =
|
|
36
|
+
if keys.include?(column)
|
|
37
|
+
Sorting.call(@groups) { _1[column].to_s }
|
|
38
|
+
elsif column == :score
|
|
39
|
+
Sorting.call(@groups, descending: true) { [Rational(_1[:solved], _1[:attempts]), _1[:attempts]] }
|
|
40
|
+
else
|
|
41
|
+
Sorting.call(@groups, descending: true) { _1[METRIC_SOURCES.fetch(column)] }
|
|
42
|
+
end
|
|
43
|
+
self
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def to_rows
|
|
47
|
+
[keys.map(&:to_s) + METRICS.map(&:to_s)] +
|
|
48
|
+
@groups.map do |group|
|
|
49
|
+
keys.map { |key| display_key(key, group[key]) } + [
|
|
50
|
+
"#{group[:solved]}/#{group[:attempts]}",
|
|
51
|
+
time(group[:duration_sec]),
|
|
52
|
+
cost(group[:cost_usd]),
|
|
53
|
+
mean_display(group[:steps], 1),
|
|
54
|
+
mean_display(group[:tokens], 0)
|
|
55
|
+
]
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def to_csv
|
|
60
|
+
columns = keys + %i[solved attempts duration_sec cost_usd steps tokens]
|
|
61
|
+
CSV.generate do |csv|
|
|
62
|
+
csv << columns
|
|
63
|
+
@groups.each { |group| csv << columns.map { group[_1] } }
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def summary = report.summary
|
|
68
|
+
|
|
69
|
+
def summary_lines = report.summary_lines
|
|
70
|
+
|
|
71
|
+
private
|
|
72
|
+
|
|
73
|
+
# Attempts count every run; means and the median skip runs that never
|
|
74
|
+
# measured the value, so one invalid trial cannot zero out a cell.
|
|
75
|
+
def build(values, group)
|
|
76
|
+
keys.zip(values).to_h.merge(
|
|
77
|
+
solved: Tally.call(group)[:solved],
|
|
78
|
+
attempts: group.size,
|
|
79
|
+
duration_sec: median(group.filter_map { _1[:duration_sec] }),
|
|
80
|
+
cost_usd: mean(group.filter_map { _1[:cost_usd] }),
|
|
81
|
+
steps: mean(group.filter_map { _1[:steps] }),
|
|
82
|
+
tokens: mean(group.filter_map { _1[:tokens] })
|
|
83
|
+
)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def mean(values) = values.empty? ? nil : values.sum(0.0) / values.size
|
|
87
|
+
|
|
88
|
+
def median(values)
|
|
89
|
+
return nil if values.empty?
|
|
90
|
+
|
|
91
|
+
sorted = values.sort
|
|
92
|
+
mid = sorted.size / 2
|
|
93
|
+
sorted.size.odd? ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2.0
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def display_key(key, value)
|
|
97
|
+
return "-" if value.nil?
|
|
98
|
+
|
|
99
|
+
key == :model ? Report.short_model(value) : value.to_s
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def time(sec)
|
|
103
|
+
return "-" if sec.nil?
|
|
104
|
+
|
|
105
|
+
minutes, seconds = sec.round.divmod(60)
|
|
106
|
+
minutes.positive? ? "#{minutes}m #{seconds}s" : "#{seconds}s"
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def cost(value) = value.nil? ? "-" : "$#{format("%g", value.round(4))}"
|
|
110
|
+
|
|
111
|
+
def mean_display(value, digits) = value.nil? ? "-" : format("%g", value.round(digits))
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lemans
|
|
4
|
+
module Results
|
|
5
|
+
# Where a trial's dollar figure came from: a published $0.00 is only worth
|
|
6
|
+
# reading if it can be told apart from "nobody could price this model".
|
|
7
|
+
CostSource = Data.define(:name, :model, :priced_as, :registry) do
|
|
8
|
+
def self.none = new(name: :none, model: nil, priced_as: nil, registry: nil)
|
|
9
|
+
|
|
10
|
+
def to_h = { name: name, model: model, priced_as: priced_as, registry: registry }.compact
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lemans
|
|
4
|
+
module Results
|
|
5
|
+
# Why a trial ended, and whether its reward means anything: out-of-budget
|
|
6
|
+
# is a scored failure, a sandbox that never started measured nothing.
|
|
7
|
+
class Outcome
|
|
8
|
+
SCORED = %i[completed agent_timeout step_limit_reached cost_ceiling_reached].freeze
|
|
9
|
+
INVALID = %i[environment_error agent_error accounting_error verifier_error cancelled harness_crash].freeze
|
|
10
|
+
|
|
11
|
+
ALL = (SCORED + INVALID).freeze
|
|
12
|
+
|
|
13
|
+
attr_reader :name, :detail
|
|
14
|
+
|
|
15
|
+
def initialize(name, detail: nil)
|
|
16
|
+
raise ArgumentError, "unknown outcome #{name.inspect}" unless ALL.include?(name)
|
|
17
|
+
|
|
18
|
+
@name = name
|
|
19
|
+
@detail = detail
|
|
20
|
+
freeze
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
ALL.each do |outcome|
|
|
24
|
+
define_method(:"#{outcome}?") { name == outcome }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def scored? = SCORED.include?(name)
|
|
28
|
+
|
|
29
|
+
def invalid? = !scored?
|
|
30
|
+
|
|
31
|
+
def to_h = { name: name, scored: scored?, detail: detail }.compact
|
|
32
|
+
|
|
33
|
+
def to_s = detail ? "#{name}: #{detail}" : name.to_s
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "csv"
|
|
4
|
+
require "json"
|
|
5
|
+
require "pathname"
|
|
6
|
+
|
|
7
|
+
module Lemans
|
|
8
|
+
module Results
|
|
9
|
+
# Reads a runs directory back as a table or CSV. The result files stay the
|
|
10
|
+
# source of truth; unreadable ones are counted and said out loud.
|
|
11
|
+
class Report
|
|
12
|
+
COLUMNS = %i[task agent model reward outcome scored cost_usd steps tokens duration_sec started_at trial tags
|
|
13
|
+
detail].freeze
|
|
14
|
+
TABLE_COLUMNS = %i[task agent model reward outcome cost_usd steps tokens duration_sec trial].freeze
|
|
15
|
+
NUMERIC_COLUMNS = %i[reward cost_usd steps tokens duration_sec].freeze
|
|
16
|
+
|
|
17
|
+
attr_reader :rows, :unreadable
|
|
18
|
+
|
|
19
|
+
def self.load(runs_dir, tag: nil)
|
|
20
|
+
paths = Pathname(runs_dir).glob("**/result.json").sort
|
|
21
|
+
rows = []
|
|
22
|
+
unreadable = 0
|
|
23
|
+
|
|
24
|
+
paths.each do |path|
|
|
25
|
+
result = JSON.parse(path.read)
|
|
26
|
+
rows << row_from(result)
|
|
27
|
+
rescue JSON::ParserError, SystemCallError, IOError
|
|
28
|
+
unreadable += 1
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
rows = rows.select { _1[:tags].include?(tag) } if tag
|
|
32
|
+
new(rows: rows.sort_by { [_1[:task].to_s, _1[:started_at].to_s] }, unreadable: unreadable)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.row_from(result)
|
|
36
|
+
{
|
|
37
|
+
task: result["task"],
|
|
38
|
+
agent: result["agent"],
|
|
39
|
+
model: result["model"],
|
|
40
|
+
reward: result["reward"],
|
|
41
|
+
outcome: result.dig("outcome", "name"),
|
|
42
|
+
scored: result.dig("outcome", "scored") == true,
|
|
43
|
+
detail: result.dig("outcome", "detail"),
|
|
44
|
+
cost_usd: result.dig("usage", "cost_usd"),
|
|
45
|
+
steps: result.dig("usage", "steps"),
|
|
46
|
+
tokens: tokens_from(result),
|
|
47
|
+
duration_sec: result["duration_sec"],
|
|
48
|
+
started_at: result["started_at"],
|
|
49
|
+
trial: result["trial"],
|
|
50
|
+
tags: Array(result["tags"]).map(&:to_s)
|
|
51
|
+
}
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Tokens the model actually consumed and produced; cache reads stay out,
|
|
55
|
+
# matching how providers meter a run.
|
|
56
|
+
def self.tokens_from(result)
|
|
57
|
+
input = result.dig("usage", "input_tokens")
|
|
58
|
+
output = result.dig("usage", "output_tokens")
|
|
59
|
+
input.nil? && output.nil? ? nil : input.to_i + output.to_i
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# A bench may name no model at all (nop, oracle); the summary needs a
|
|
63
|
+
# label, not a nil for ljust to crash on.
|
|
64
|
+
def self.short_model(model) = model.to_s.split("/").last || "(default)"
|
|
65
|
+
|
|
66
|
+
def initialize(rows:, unreadable: 0)
|
|
67
|
+
@rows = rows
|
|
68
|
+
@unreadable = unreadable
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def empty? = rows.empty? && unreadable.zero?
|
|
72
|
+
|
|
73
|
+
# Numbers rank best-first the way a leaderboard reads; names sort A-Z.
|
|
74
|
+
# Trials that never measured the column sink to the bottom either way.
|
|
75
|
+
def order_by!(column)
|
|
76
|
+
column = Sorting.column(column, allowed: TABLE_COLUMNS)
|
|
77
|
+
descending = NUMERIC_COLUMNS.include?(column)
|
|
78
|
+
@rows = Sorting.call(rows, descending: descending) { _1[column] }
|
|
79
|
+
self
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def summary
|
|
83
|
+
Tally.call(rows).merge(cost_usd: rows.sum { _1[:cost_usd].to_f })
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def to_rows
|
|
87
|
+
[TABLE_COLUMNS.map(&:to_s)] +
|
|
88
|
+
rows.map do |row|
|
|
89
|
+
TABLE_COLUMNS.map do |column|
|
|
90
|
+
display(column == :model ? short_model(row[:model]) : row[column])
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def summary_lines
|
|
96
|
+
per_model = rows.group_by { short_model(_1[:model]) }
|
|
97
|
+
lines =
|
|
98
|
+
if per_model.size > 1
|
|
99
|
+
width = per_model.keys.map(&:length).max
|
|
100
|
+
per_model.map { |model, group| "#{model.ljust(width)} #{stats(group)}" } +
|
|
101
|
+
["#{"total".ljust(width)} #{stats(rows)}"]
|
|
102
|
+
else
|
|
103
|
+
[stats(rows)]
|
|
104
|
+
end
|
|
105
|
+
lines[-1] = "#{lines[-1]} · #{unreadable} unreadable result(s) skipped" if unreadable.positive?
|
|
106
|
+
lines
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def to_csv
|
|
110
|
+
CSV.generate do |csv|
|
|
111
|
+
csv << COLUMNS
|
|
112
|
+
rows.each do |row|
|
|
113
|
+
csv << COLUMNS.map { |column| column == :tags ? Array(row[:tags]).join(" ") : row[column] }
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
private
|
|
119
|
+
|
|
120
|
+
# The rank divides solved by scored, not total: invalid trials measured nothing.
|
|
121
|
+
def stats(group)
|
|
122
|
+
totals = Tally.call(group).merge(cost_usd: group.sum { _1[:cost_usd].to_f })
|
|
123
|
+
rank = totals[:scored].positive? ? " (#{(100.0 * totals[:solved] / totals[:scored]).round}%)" : ""
|
|
124
|
+
"#{totals[:total]} trials: #{totals[:scored]} scored, #{totals[:invalid]} invalid, " \
|
|
125
|
+
"#{totals[:solved]} solved#{rank} · $#{format("%.4f", totals[:cost_usd])}#{pass_at_k(group)}"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def pass_at_k(group)
|
|
129
|
+
cells = group.select { _1[:scored] }.group_by { [_1[:model], _1[:task]] }.values
|
|
130
|
+
sizes = cells.map(&:size).uniq
|
|
131
|
+
return "" unless sizes.any? { _1 > 1 }
|
|
132
|
+
|
|
133
|
+
solved = cells.count { |trials| trials.any? { _1[:reward].to_f >= 1.0 } }
|
|
134
|
+
label = sizes.size == 1 ? "pass@#{sizes.first}" : "pass@k"
|
|
135
|
+
" · #{label} #{solved}/#{cells.size} tasks (#{(100.0 * solved / cells.size).round}%)"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def short_model(model) = self.class.short_model(model)
|
|
139
|
+
|
|
140
|
+
def display(value)
|
|
141
|
+
case value
|
|
142
|
+
when nil then "-"
|
|
143
|
+
when Float then format("%g", value.round(4))
|
|
144
|
+
else value.to_s
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lemans
|
|
4
|
+
module Results
|
|
5
|
+
# One sorting rule for every results view: validate the column name and
|
|
6
|
+
# keep rows that never measured the value at the bottom.
|
|
7
|
+
module Sorting
|
|
8
|
+
def self.column(name, allowed:)
|
|
9
|
+
column = name.to_s.to_sym
|
|
10
|
+
return column if allowed.include?(column)
|
|
11
|
+
|
|
12
|
+
raise ConfigError, "--sort: unknown column #{name.inspect} (try #{allowed.join(", ")})"
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.call(rows, descending: false)
|
|
16
|
+
keyed = rows.map { [yield(_1), _1] }
|
|
17
|
+
present, missing = keyed.partition { |value, _| value }
|
|
18
|
+
sorted = present.sort_by { |value, _| value }
|
|
19
|
+
sorted.reverse! if descending
|
|
20
|
+
(sorted + missing).map(&:last)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lemans
|
|
4
|
+
module Results
|
|
5
|
+
# One definition of the numbers everyone quotes — total, scored, invalid, solved — so the
|
|
6
|
+
# streaming summary and the report can never drift apart.
|
|
7
|
+
module Tally
|
|
8
|
+
def self.call(entries)
|
|
9
|
+
scored = entries.count { _1[:scored] }
|
|
10
|
+
{
|
|
11
|
+
total: entries.size,
|
|
12
|
+
scored: scored,
|
|
13
|
+
invalid: entries.size - scored,
|
|
14
|
+
solved: entries.count { _1[:reward].to_f >= 1.0 }
|
|
15
|
+
}
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lemans
|
|
4
|
+
module Results
|
|
5
|
+
# What a trial spent. Cache tokens count separately, and the cost carries
|
|
6
|
+
# its own provenance — $0.00 is the figure most in need of auditing.
|
|
7
|
+
Usage = Data.define(:input_tokens, :output_tokens, :cached_tokens, :cost_usd, :steps, :cost_source) do
|
|
8
|
+
def self.zero
|
|
9
|
+
new(input_tokens: 0, output_tokens: 0, cached_tokens: 0, cost_usd: 0.0, steps: 0, cost_source: CostSource.none)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def to_h
|
|
13
|
+
{
|
|
14
|
+
input_tokens: input_tokens,
|
|
15
|
+
output_tokens: output_tokens,
|
|
16
|
+
cached_tokens: cached_tokens,
|
|
17
|
+
cost_usd: cost_usd,
|
|
18
|
+
steps: steps,
|
|
19
|
+
cost_source: cost_source&.to_h
|
|
20
|
+
}
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
data/lib/lemans/run.rb
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "concurrent"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Lemans
|
|
7
|
+
# A whole run: every task, k attempts each, several trials in flight.
|
|
8
|
+
# Concurrency and resume exist because a sequential run is a day of wall clock.
|
|
9
|
+
class Run
|
|
10
|
+
Attempt = Data.define(:task, :model, :index)
|
|
11
|
+
|
|
12
|
+
# What ^C injects into workers instead of Interrupt, so the join loop can
|
|
13
|
+
# swallow its own stop signal while a user's second ^C stays distinguishable
|
|
14
|
+
class Shutdown < Exception; end # rubocop:disable Lint/InheritException
|
|
15
|
+
|
|
16
|
+
def initialize(bench:, tasks:, agent_name:, runs_dir:, attempts: 1, concurrency: 4,
|
|
17
|
+
resume: false, model: nil, backend: "daytona")
|
|
18
|
+
@bench = bench
|
|
19
|
+
@tasks = tasks
|
|
20
|
+
@agent_name = agent_name
|
|
21
|
+
@runs_dir = Pathname(runs_dir)
|
|
22
|
+
@attempts = attempts
|
|
23
|
+
@concurrency = concurrency
|
|
24
|
+
@resume = resume
|
|
25
|
+
@model = Array(model)
|
|
26
|
+
@backend = backend
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# How many attempts this run will actually schedule — resume already
|
|
30
|
+
# subtracted the retired ones.
|
|
31
|
+
def total = pending.size
|
|
32
|
+
|
|
33
|
+
def call(&report)
|
|
34
|
+
begin
|
|
35
|
+
@runs_dir.mkpath
|
|
36
|
+
rescue SystemCallError => e
|
|
37
|
+
raise ConfigError, "cannot use runs directory #{@runs_dir}: #{e.message}"
|
|
38
|
+
end
|
|
39
|
+
queue = Queue.new
|
|
40
|
+
pending.shuffle.each { queue << _1 }
|
|
41
|
+
queue.close
|
|
42
|
+
|
|
43
|
+
results = Concurrent::Array.new
|
|
44
|
+
workers = Array.new(@concurrency) { Thread.new { drain(queue, results, &report) } }
|
|
45
|
+
workers.each(&:join)
|
|
46
|
+
raise @abort if @abort
|
|
47
|
+
|
|
48
|
+
summarize(results)
|
|
49
|
+
rescue Interrupt
|
|
50
|
+
queue&.clear
|
|
51
|
+
queue&.close
|
|
52
|
+
workers = Array(workers).select(&:alive?)
|
|
53
|
+
report&.call(:interrupted, { in_flight: workers.size })
|
|
54
|
+
workers.each { _1.raise(Shutdown) }
|
|
55
|
+
|
|
56
|
+
workers.each do |worker| # rubocop:disable Style/CombinableLoops
|
|
57
|
+
worker.join
|
|
58
|
+
rescue Shutdown
|
|
59
|
+
# ignore: our own stop signal coming back
|
|
60
|
+
end
|
|
61
|
+
summarize(results || []).merge(interrupted: true)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def pending
|
|
67
|
+
@pending ||= models.flat_map do |model|
|
|
68
|
+
@tasks.flat_map do |task|
|
|
69
|
+
completed = @resume ? completed_attempts(task, model) : 0
|
|
70
|
+
((completed + 1)..@attempts).map { Attempt.new(task: task, model: model, index: _1) }
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def models
|
|
76
|
+
return @model if @model.any?
|
|
77
|
+
return [nil] if @bench.agent.models.empty?
|
|
78
|
+
|
|
79
|
+
@bench.agent.models
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Counts only attempts that measured the same thing (agent, model, digests, scored) — otherwise
|
|
83
|
+
# editing bench.yml and resuming would let old-profile trials satisfy the new run.
|
|
84
|
+
def completed_attempts(task, model)
|
|
85
|
+
@runs_dir.glob("**/result.json").count { same_run?(_1, task, model) }
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def same_run?(path, task, model)
|
|
89
|
+
result = JSON.parse(path.read, symbolize_names: true)
|
|
90
|
+
|
|
91
|
+
result[:task] == task.name &&
|
|
92
|
+
result[:agent] == @agent_name &&
|
|
93
|
+
result[:model] == (model || @bench.agent.model) &&
|
|
94
|
+
result[:profile_digest] == @bench.digest &&
|
|
95
|
+
result[:task_digest] == task.digest &&
|
|
96
|
+
result.dig(:outcome, :scored) == true
|
|
97
|
+
rescue JSON::ParserError, SystemCallError, IOError
|
|
98
|
+
false
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Any failure that escapes a trial poisons the run: stop scheduling, let
|
|
102
|
+
# in-flight trials finish, surface after the join.
|
|
103
|
+
def drain(queue, results, &)
|
|
104
|
+
# The TUI owns failure output; a dying worker must not spray stderr.
|
|
105
|
+
Thread.current.report_on_exception = false
|
|
106
|
+
while (attempt = queue.pop)
|
|
107
|
+
begin
|
|
108
|
+
results << run_attempt(attempt, &)
|
|
109
|
+
rescue StandardError => e
|
|
110
|
+
@abort ||= e
|
|
111
|
+
queue.clear
|
|
112
|
+
queue.close
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def run_attempt(attempt)
|
|
118
|
+
trial = Trial.new(
|
|
119
|
+
task: attempt.task,
|
|
120
|
+
bench: @bench,
|
|
121
|
+
agent_name: @agent_name,
|
|
122
|
+
model: attempt.model,
|
|
123
|
+
backend: @backend,
|
|
124
|
+
runs_dir: @runs_dir
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
if block_given?
|
|
128
|
+
yield :started, { task: attempt.task.name, model: attempt.model || @bench.agent.model,
|
|
129
|
+
index: attempt.index, attempts: @attempts, trial: trial.id }
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
result = trial.run
|
|
133
|
+
if block_given?
|
|
134
|
+
yield :finished, {
|
|
135
|
+
task: attempt.task.name,
|
|
136
|
+
model: attempt.model || @bench.agent.model,
|
|
137
|
+
index: attempt.index,
|
|
138
|
+
outcome: result[:outcome][:name],
|
|
139
|
+
scored: result[:outcome][:scored],
|
|
140
|
+
reward: result[:reward],
|
|
141
|
+
duration_sec: result[:duration_sec],
|
|
142
|
+
detail: result[:outcome][:detail]
|
|
143
|
+
}
|
|
144
|
+
end
|
|
145
|
+
result
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def summarize(results)
|
|
149
|
+
Results::Tally.call(results.map { { scored: _1.dig(:outcome, :scored) == true, reward: _1[:reward] } })
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
data/lib/lemans/setup.rb
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "shellwords"
|
|
4
|
+
|
|
5
|
+
module Lemans
|
|
6
|
+
# Makes one shared image into this task's sandbox at start-up: uploads the
|
|
7
|
+
# declared files and runs the setup commands. A failure here is never the model's.
|
|
8
|
+
class Setup
|
|
9
|
+
# Uploads land under one harness-owned directory, wiped once the steps have
|
|
10
|
+
# run: an agent whose first `ls /` finds the seed patch is reading the harness.
|
|
11
|
+
ROOT = "/lemans"
|
|
12
|
+
DIR = "#{ROOT}/setup".freeze
|
|
13
|
+
|
|
14
|
+
def initialize(commands:, task:, phase:, timeout_sec:)
|
|
15
|
+
@commands = commands
|
|
16
|
+
@task = task
|
|
17
|
+
@phase = phase
|
|
18
|
+
@timeout_sec = timeout_sec
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def call(environment)
|
|
22
|
+
return if commands.empty? && files.empty?
|
|
23
|
+
|
|
24
|
+
files.each { |local, remote| environment.upload(local, remote) }
|
|
25
|
+
apply_seed(environment)
|
|
26
|
+
commands.each { environment.exec!(_1, timeout: timeout_sec) }
|
|
27
|
+
environment.exec!("rm -rf #{Shellwords.escape(ROOT)}")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
attr_reader :commands, :task, :phase, :timeout_sec
|
|
33
|
+
|
|
34
|
+
# Folds a task's flat environment.patch into the workdir, then reseals the
|
|
35
|
+
# tree as a one-commit repo so `git log` does not point at the defect.
|
|
36
|
+
def apply_seed(environment)
|
|
37
|
+
return unless phase == :environment
|
|
38
|
+
|
|
39
|
+
seed = "#{DIR}/#{Task::FLAT_SEED}"
|
|
40
|
+
return unless files.any? { |_, remote| remote == seed }
|
|
41
|
+
|
|
42
|
+
workdir = Shellwords.escape(task.bench.environment.workdir)
|
|
43
|
+
environment.exec!(
|
|
44
|
+
"cd #{workdir} && git apply --binary --whitespace=nowarn #{Shellwords.escape(seed)} && " \
|
|
45
|
+
"rm -rf .git && git init -q && git add -A && " \
|
|
46
|
+
"git -c user.name=lemans -c user.email=lemans@localhost commit -qm 'Initial commit'",
|
|
47
|
+
timeout: timeout_sec
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def files
|
|
52
|
+
@files ||= from(bench.root, bench.setup_files(phase)) + from(task.dir, task.setup_files(phase))
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def from(root, paths) = paths.map { [root.join(_1), "#{DIR}/#{_1}"] }
|
|
56
|
+
|
|
57
|
+
def bench = task.bench
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
|
|
5
|
+
module Lemans
|
|
6
|
+
# The files a phase's setup steps consume. Paths are confined to the
|
|
7
|
+
# directory that declared them and must exist at load time.
|
|
8
|
+
module SetupFiles
|
|
9
|
+
PHASES = %i[environment verifier].freeze
|
|
10
|
+
|
|
11
|
+
def self.call(declared, root:, label:)
|
|
12
|
+
declared ||= {}
|
|
13
|
+
raise ConfigError, "#{label}: files must name a phase (#{PHASES.join(", ")})" unless declared.is_a?(Hash)
|
|
14
|
+
|
|
15
|
+
unknown = declared.keys.map(&:to_s) - PHASES.map(&:to_s)
|
|
16
|
+
raise ConfigError, "#{label}: files.#{unknown.first} is not a phase (#{PHASES.join(", ")})" if unknown.any?
|
|
17
|
+
|
|
18
|
+
root = Pathname(root)
|
|
19
|
+
PHASES.to_h do |phase|
|
|
20
|
+
[phase, Array(declared[phase.to_s]).map { checked(_1, phase, root: root, label: label) }.freeze]
|
|
21
|
+
end.freeze
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.checked(path, phase, root:, label:)
|
|
25
|
+
raise ConfigError, "#{label}: files.#{phase} entry #{path.inspect} is not a path" unless path.is_a?(String) && !path.empty?
|
|
26
|
+
raise ConfigError, "#{label}: files.#{phase} entry #{path.inspect} must be relative to #{root}" if path.start_with?("/")
|
|
27
|
+
|
|
28
|
+
relative = Pathname(path).cleanpath
|
|
29
|
+
raise ConfigError, "#{label}: files.#{phase} entry #{path.inspect} must name a file inside #{root}" if relative.each_filename.include?("..") || relative.to_s == "."
|
|
30
|
+
raise ConfigError, "#{label}: files.#{phase} names #{path}, which is not a file" unless root.join(relative).file?
|
|
31
|
+
|
|
32
|
+
relative
|
|
33
|
+
end
|
|
34
|
+
private_class_method :checked
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "shellwords"
|
|
4
|
+
|
|
5
|
+
module Lemans
|
|
6
|
+
# The sealed baseline of a task's graded surfaces: a git tree written before
|
|
7
|
+
# the agent's first turn, checked out over the live tree at verification.
|
|
8
|
+
class Snapshot
|
|
9
|
+
REMOTE_INDEX = "/tmp/lemans-baseline.idx"
|
|
10
|
+
|
|
11
|
+
TIMEOUT = 300
|
|
12
|
+
|
|
13
|
+
def initialize(environment, bench:, task:, timeout: TIMEOUT)
|
|
14
|
+
@environment = environment
|
|
15
|
+
@workdir = bench.environment.workdir
|
|
16
|
+
@paths = task.restore_paths
|
|
17
|
+
@timeout = timeout
|
|
18
|
+
@baseline = nil
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Sealing failure is an environment error: the agent has not run yet.
|
|
22
|
+
def capture!
|
|
23
|
+
return if paths.empty?
|
|
24
|
+
|
|
25
|
+
result = environment.exec(
|
|
26
|
+
"rm -f #{REMOTE_INDEX} && GIT_INDEX_FILE=#{REMOTE_INDEX} #{git} add -A && " \
|
|
27
|
+
"GIT_INDEX_FILE=#{REMOTE_INDEX} #{git} write-tree && rm -f #{REMOTE_INDEX}",
|
|
28
|
+
timeout: timeout
|
|
29
|
+
)
|
|
30
|
+
tree = result.output.to_s.lines.map(&:strip).reject(&:empty?).last
|
|
31
|
+
raise InfrastructureError, "could not seal the graded surfaces: #{result.output.to_s[0, 500]}" unless result.success? && /\A[0-9a-f]{40,64}\z/.match?(tree.to_s)
|
|
32
|
+
|
|
33
|
+
@baseline = tree
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def restore! # rubocop:disable Naming/PredicateMethod
|
|
37
|
+
return true if paths.empty?
|
|
38
|
+
raise VerifierError, "restore is declared but no baseline was sealed" unless baseline
|
|
39
|
+
|
|
40
|
+
environment.exec(
|
|
41
|
+
"cd #{Shellwords.escape(workdir)} && #{git} cat-file -e #{baseline} && " \
|
|
42
|
+
"rm -rf -- #{escaped_paths} && #{git} checkout #{baseline} -- #{escaped_paths}",
|
|
43
|
+
timeout: timeout
|
|
44
|
+
).success?
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
attr_reader :environment, :workdir, :paths, :timeout, :baseline
|
|
50
|
+
|
|
51
|
+
def git = "git -c safe.directory='*' -C #{Shellwords.escape(workdir)}"
|
|
52
|
+
|
|
53
|
+
def escaped_paths = Shellwords.join(paths)
|
|
54
|
+
end
|
|
55
|
+
end
|