lemans 1.4.0 → 1.5.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 +16 -0
- data/README.md +17 -2
- data/exe/lemans-remote +141 -3
- data/exe/lemans-viewer +998 -0
- data/lib/lemans/agent.rb +4 -1
- data/lib/lemans/agents/miniswen.rb +10 -5
- data/lib/lemans/agents/miniswen_installed.rb +16 -5
- data/lib/lemans/agents/nop.rb +1 -1
- data/lib/lemans/agents/oracle.rb +1 -1
- data/lib/lemans/agents.rb +4 -2
- data/lib/lemans/cli/regrade.rb +29 -77
- data/lib/lemans/cli/report/aggregate.rb +54 -30
- data/lib/lemans/cli/report.rb +168 -28
- data/lib/lemans/cli.rb +110 -34
- data/lib/lemans/environments/docker/proxy/Dockerfile +4 -0
- data/lib/lemans/environments/docker/proxy/README.md +49 -0
- data/lib/lemans/environments/docker/proxy/server.rb +79 -0
- data/lib/lemans/environments/docker.rb +79 -16
- data/lib/lemans/result.rb +65 -6
- data/lib/lemans/runner/task.rb +9 -5
- data/lib/lemans/runner.rb +45 -2
- data/lib/lemans/store.rb +6 -0
- data/lib/lemans/stores/fs.rb +16 -4
- data/lib/lemans/trial/patch.rb +21 -0
- data/lib/lemans/trial/verifier/test_scanner.rb +136 -0
- data/lib/lemans/trial/verifier.rb +19 -10
- data/lib/lemans/trial.rb +108 -40
- data/lib/lemans/version.rb +1 -1
- data/lib/lemans.rb +2 -0
- data/lib/miniswen/agent.rb +52 -16
- data/lib/miniswen/cli.rb +8 -3
- data/lib/miniswen/environment/docker.rb +1 -1
- data/lib/miniswen/jail.rb +1 -1
- data/lib/miniswen/local.rb +1 -1
- data/lib/miniswen/version.rb +1 -1
- metadata +49 -1
data/lib/lemans/cli/report.rb
CHANGED
|
@@ -7,18 +7,20 @@ module Lemans
|
|
|
7
7
|
# Renders stored results as a table or CSV. The store is the source of
|
|
8
8
|
# truth; rows are plain hashes derived from Result records.
|
|
9
9
|
class Report
|
|
10
|
-
COLUMNS = %i[task agent model reward credit
|
|
11
|
-
tags detail].freeze
|
|
12
|
-
TABLE_COLUMNS = %i[task agent model reward credit outcome cost_usd steps tokens duration
|
|
13
|
-
|
|
10
|
+
COLUMNS = %i[task agent model reward credit completed_steps total_steps features_passed features_total outcome
|
|
11
|
+
scored cost_usd steps tokens duration started_at trial tags detail].freeze
|
|
12
|
+
TABLE_COLUMNS = %i[task agent model reward credit progress features outcome cost_usd steps tokens duration
|
|
13
|
+
trial].freeze
|
|
14
|
+
NUMERIC_COLUMNS = %i[reward credit progress features cost_usd steps tokens duration].freeze
|
|
14
15
|
|
|
15
|
-
attr_reader :rows, :unreadable
|
|
16
|
+
attr_reader :rows, :unreadable, :show_features, :hide_columns
|
|
16
17
|
|
|
17
18
|
class << self
|
|
18
|
-
def load(store, tags: nil, names: nil, metadata: nil)
|
|
19
|
+
def load(store, tags: nil, names: nil, metadata: nil, skip_invalid: false, **)
|
|
19
20
|
rows = store.query(task: names, tags:, metadata:).map { row_from(it) }
|
|
21
|
+
rows = rows.select { it[:scored] } if skip_invalid
|
|
20
22
|
new(rows.sort_by { [ it[:task].to_s, it[:started_at].to_s, it[:trial].to_s ] },
|
|
21
|
-
unreadable: store.unreadable.size)
|
|
23
|
+
unreadable: store.unreadable.size, **)
|
|
22
24
|
end
|
|
23
25
|
|
|
24
26
|
def row_from(result)
|
|
@@ -29,6 +31,9 @@ module Lemans
|
|
|
29
31
|
model: result.model,
|
|
30
32
|
reward: result.reward,
|
|
31
33
|
credit: result.credit,
|
|
34
|
+
features: result.features,
|
|
35
|
+
completed_steps: result.steps&.size,
|
|
36
|
+
total_steps: result.total_steps,
|
|
32
37
|
outcome: result.status,
|
|
33
38
|
scored: result.scored?,
|
|
34
39
|
detail: result.detail,
|
|
@@ -44,6 +49,38 @@ module Lemans
|
|
|
44
49
|
}
|
|
45
50
|
end
|
|
46
51
|
|
|
52
|
+
# Steps completed out of the task's steps; unknown for older runs halted midway
|
|
53
|
+
def progress_ratio(row) = row[:total_steps] && Rational(row[:completed_steps], row[:total_steps])
|
|
54
|
+
|
|
55
|
+
# Feature columns carry a prefix no regular column has
|
|
56
|
+
def feature_column(name) = :"feat:#{name}"
|
|
57
|
+
|
|
58
|
+
def feature_of(row, column) = row[:features]&.[](column.to_s.delete_prefix("feat:"))
|
|
59
|
+
|
|
60
|
+
# Features passed and graded in a run, nil when it graded none
|
|
61
|
+
def features_tally(row)
|
|
62
|
+
features = row[:features]
|
|
63
|
+
[ features.count { |_, passed| passed }, features.size ] if features && !features.empty?
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def duration_display(sec)
|
|
67
|
+
return "-" if sec.nil?
|
|
68
|
+
|
|
69
|
+
minutes, seconds = sec.round.divmod(60)
|
|
70
|
+
minutes.positive? ? "#{minutes}m #{seconds}s" : "#{seconds}s"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def cost_display(value) = value.nil? ? "-" : "$#{format("%g", value.round(4))}"
|
|
74
|
+
|
|
75
|
+
def tokens_display(value)
|
|
76
|
+
return "-" if value.nil?
|
|
77
|
+
|
|
78
|
+
[ [ 1e9, "B" ], [ 1e6, "M" ], [ 1e3, "K" ] ].each do |unit, suffix|
|
|
79
|
+
return format("%.1f#{suffix}", value / unit) if value >= unit
|
|
80
|
+
end
|
|
81
|
+
value.round.to_s
|
|
82
|
+
end
|
|
83
|
+
|
|
47
84
|
# A bench may name no model at all (nop, oracle); the summary needs a
|
|
48
85
|
# label, not a nil for ljust to crash on.
|
|
49
86
|
def short_model(model) = model.to_s.split("/").last || "(default)"
|
|
@@ -72,27 +109,63 @@ module Lemans
|
|
|
72
109
|
end
|
|
73
110
|
end
|
|
74
111
|
|
|
75
|
-
# One sorting rule for every view
|
|
76
|
-
#
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
112
|
+
# One sorting rule for every view. `score-credit` sorts by score, then
|
|
113
|
+
# credit; `^` reverses a column's natural order. Column names may hold
|
|
114
|
+
# dashes (features), so the longest known name wins.
|
|
115
|
+
def sort_columns(spec, allowed:)
|
|
116
|
+
columns, unknown = split_columns(spec, allowed:)
|
|
117
|
+
return columns if unknown.empty? && columns.any?
|
|
118
|
+
|
|
119
|
+
raise ConfigError, "--sort: unknown column in #{spec.inspect} (try #{allowed.join(", ")})"
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# `--hide-columns steps-tokens`: names nobody knows are let go
|
|
123
|
+
def hidden_columns(spec, allowed:) = split_columns(spec, allowed:).first.map(&:first)
|
|
124
|
+
|
|
125
|
+
# [[column, reversed], ...] and the parts that name no column
|
|
126
|
+
def split_columns(spec, allowed:)
|
|
127
|
+
names = allowed.map(&:to_s).sort_by { -it.length }
|
|
128
|
+
rest = spec.to_s
|
|
129
|
+
columns = []
|
|
130
|
+
unknown = []
|
|
131
|
+
until rest.empty?
|
|
132
|
+
reversed = rest.start_with?("^")
|
|
133
|
+
rest = rest.delete_prefix("^")
|
|
134
|
+
if (name = names.find { rest == it || rest.start_with?("#{it}-") })
|
|
135
|
+
columns << [ name.to_sym, reversed ]
|
|
136
|
+
else
|
|
137
|
+
unknown << (name = rest[/\A[^-]*/])
|
|
138
|
+
end
|
|
139
|
+
rest = rest.delete_prefix(name).delete_prefix("-")
|
|
140
|
+
end
|
|
141
|
+
[ columns, unknown ]
|
|
142
|
+
end
|
|
80
143
|
|
|
81
|
-
|
|
144
|
+
# Keys are [value, descending] pairs, tried in order; rows that never
|
|
145
|
+
# measured a value sink below the rest, and ties keep their order.
|
|
146
|
+
def sort_rows(rows, keys)
|
|
147
|
+
keyed = rows.each_with_index.map { |row, index| [ keys.map { |value, _| value.call(row) }, index, row ] }
|
|
148
|
+
keyed.sort { |(a, i, _), (b, j, _)| compare(a, b, keys.map(&:last)).nonzero? || i <=> j }.map(&:last)
|
|
82
149
|
end
|
|
83
150
|
|
|
84
|
-
def
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
151
|
+
private def compare(values, others, descending)
|
|
152
|
+
values.zip(others, descending).each do |value, other, desc|
|
|
153
|
+
next if value == other
|
|
154
|
+
return 1 if value.nil?
|
|
155
|
+
return -1 if other.nil?
|
|
156
|
+
|
|
157
|
+
order = value <=> other
|
|
158
|
+
return desc ? -order : order unless order.zero?
|
|
159
|
+
end
|
|
160
|
+
0
|
|
90
161
|
end
|
|
91
162
|
end
|
|
92
163
|
|
|
93
|
-
def initialize(rows, unreadable: 0)
|
|
94
|
-
@rows = rows
|
|
164
|
+
def initialize(rows, unreadable: 0, show_features: false, hide_columns: nil)
|
|
165
|
+
@rows = with_total_steps(rows)
|
|
95
166
|
@unreadable = unreadable
|
|
167
|
+
@show_features = show_features
|
|
168
|
+
@hide_columns = hide_columns
|
|
96
169
|
end
|
|
97
170
|
|
|
98
171
|
# A store holding only unreadable results is not empty: the report's
|
|
@@ -101,9 +174,12 @@ module Lemans
|
|
|
101
174
|
|
|
102
175
|
# Numbers rank best-first the way a leaderboard reads; names sort A-Z.
|
|
103
176
|
# Trials that never measured the column sink to the bottom either way.
|
|
104
|
-
def order_by!(
|
|
105
|
-
|
|
106
|
-
|
|
177
|
+
def order_by!(spec)
|
|
178
|
+
keys = self.class.sort_columns(spec, allowed: TABLE_COLUMNS + feature_columns).map do |column, reversed|
|
|
179
|
+
numeric = NUMERIC_COLUMNS.include?(column) || feature_columns.include?(column)
|
|
180
|
+
[ ->(row) { sort_value(row, column) }, numeric != reversed ]
|
|
181
|
+
end
|
|
182
|
+
@rows = self.class.sort_rows(rows, keys)
|
|
107
183
|
self
|
|
108
184
|
end
|
|
109
185
|
|
|
@@ -113,13 +189,42 @@ module Lemans
|
|
|
113
189
|
|
|
114
190
|
def fractional? = rows.any? { it[:credit] && it[:credit] != it[:reward] }
|
|
115
191
|
|
|
116
|
-
def
|
|
192
|
+
def multistep? = rows.any? { it[:completed_steps] }
|
|
193
|
+
|
|
194
|
+
def features? = rows.any? { self.class.features_tally(it) }
|
|
195
|
+
|
|
196
|
+
# The features every task in view tracks; tasks with no graded run yet have no say.
|
|
197
|
+
# Shown one column each only on request.
|
|
198
|
+
def feature_columns
|
|
199
|
+
@feature_columns ||= rows.select { it[:features] }
|
|
200
|
+
.group_by { it[:task] }
|
|
201
|
+
.map { |_, group| group.flat_map { it[:features].keys }.uniq }
|
|
202
|
+
.reduce(:&).to_a.sort.map { self.class.feature_column(it) }
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def shown_feature_columns = show_features ? feature_columns : []
|
|
206
|
+
|
|
207
|
+
def table_columns
|
|
208
|
+
columns = TABLE_COLUMNS - [ (:credit unless fractional?), (:progress unless multistep?),
|
|
209
|
+
(:features unless features?) ].compact
|
|
210
|
+
columns.insert(columns.index(:trial), *shown_feature_columns)
|
|
211
|
+
columns - self.class.hidden_columns(hide_columns, allowed: columns)
|
|
212
|
+
end
|
|
117
213
|
|
|
118
214
|
def to_rows
|
|
119
215
|
[ table_columns.map(&:to_s) ] +
|
|
120
216
|
rows.map do |row|
|
|
121
217
|
table_columns.map do |column|
|
|
122
|
-
|
|
218
|
+
case column
|
|
219
|
+
when :model then display(short_model(row[:model]))
|
|
220
|
+
when :progress then progress(row)
|
|
221
|
+
when :duration then self.class.duration_display(row[:duration])
|
|
222
|
+
when :cost_usd then self.class.cost_display(row[:cost_usd])
|
|
223
|
+
when :tokens then self.class.tokens_display(row[:tokens])
|
|
224
|
+
when :features then self.class.features_tally(row)&.join("/") || "-"
|
|
225
|
+
when *feature_columns then { true => "✓", false => "✗" }.fetch(self.class.feature_of(row, column), "-")
|
|
226
|
+
else display(row[column])
|
|
227
|
+
end
|
|
123
228
|
end
|
|
124
229
|
end
|
|
125
230
|
end
|
|
@@ -140,15 +245,48 @@ module Lemans
|
|
|
140
245
|
|
|
141
246
|
def to_csv
|
|
142
247
|
CSV.generate do |csv|
|
|
143
|
-
csv << COLUMNS
|
|
248
|
+
csv << COLUMNS + shown_feature_columns
|
|
144
249
|
rows.each do |row|
|
|
145
|
-
csv << COLUMNS.map {
|
|
250
|
+
csv << COLUMNS.map { csv_value(row, it) } +
|
|
251
|
+
shown_feature_columns.map { self.class.feature_of(row, it) }
|
|
146
252
|
end
|
|
147
253
|
end
|
|
148
254
|
end
|
|
149
255
|
|
|
150
256
|
private
|
|
151
257
|
|
|
258
|
+
def csv_value(row, column)
|
|
259
|
+
case column
|
|
260
|
+
when :tags then Array(row[:tags]).join(" ")
|
|
261
|
+
when :features_passed then self.class.features_tally(row)&.first
|
|
262
|
+
when :features_total then self.class.features_tally(row)&.last
|
|
263
|
+
else row[column]
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def sort_value(row, column)
|
|
268
|
+
if column == :model then short_model(row[:model])
|
|
269
|
+
elsif column == :progress then self.class.progress_ratio(row)
|
|
270
|
+
elsif column == :features then self.class.features_tally(row)&.then { Rational(*it) }
|
|
271
|
+
elsif feature_columns.include?(column) then { true => 1, false => 0 }[self.class.feature_of(row, column)]
|
|
272
|
+
else row[column]
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# Older multistep results lack the task's step count: a run of the same
|
|
277
|
+
# task that solved it went through every step.
|
|
278
|
+
def with_total_steps(rows)
|
|
279
|
+
known = rows.filter_map do |row|
|
|
280
|
+
total = row[:total_steps] || (row[:completed_steps] if row[:reward].to_f >= 1.0)
|
|
281
|
+
[ row[:task], total ] if total
|
|
282
|
+
end.to_h
|
|
283
|
+
rows.map do |row|
|
|
284
|
+
next row if row[:total_steps] || !row[:completed_steps] || !known[row[:task]]
|
|
285
|
+
|
|
286
|
+
row.merge(total_steps: known[row[:task]])
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
152
290
|
# The rank divides solved by scored, not total: invalid trials measured nothing.
|
|
153
291
|
def stats(group)
|
|
154
292
|
totals = self.class.tally(group).merge(cost_usd: group.sum { it[:cost_usd].to_f })
|
|
@@ -169,6 +307,8 @@ module Lemans
|
|
|
169
307
|
|
|
170
308
|
def short_model(model) = self.class.short_model(model)
|
|
171
309
|
|
|
310
|
+
def progress(row) = row[:completed_steps] ? "#{row[:completed_steps]}/#{row[:total_steps] || "?"}" : "-"
|
|
311
|
+
|
|
172
312
|
def display(value)
|
|
173
313
|
case value
|
|
174
314
|
when nil then "-"
|
data/lib/lemans/cli.rb
CHANGED
|
@@ -78,49 +78,80 @@ module Lemans
|
|
|
78
78
|
return
|
|
79
79
|
end
|
|
80
80
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
81
|
+
execute(runner, store, tasks)
|
|
82
|
+
rescue ConfigError => e
|
|
83
|
+
raise Thor::Error, "lemans: #{e.message}"
|
|
84
|
+
rescue Interrupt
|
|
85
|
+
say ""
|
|
86
|
+
exit 130
|
|
87
|
+
end
|
|
88
88
|
|
|
89
|
-
|
|
89
|
+
desc "restart RUN...", "Continue failed multistep runs from their last settled step in new runs"
|
|
90
|
+
long_desc <<~DESC
|
|
91
|
+
RUN is a run directory or a trial id; every run named restarts under the same options. The new run replays the settled steps' agent patches in a
|
|
92
|
+
fresh sandbox and starts at the next step; the failed run stays as it is. --recover also replays
|
|
93
|
+
the failed step's partial patch and lets the agent go on from its history; --reverify replays the
|
|
94
|
+
graded step's patch and runs its verification again.
|
|
95
|
+
DESC
|
|
96
|
+
option :bench, default: ".", desc: "Directory holding bench.yml"
|
|
97
|
+
option :runs_dir, default: "./runs", desc: "Directory holding the runs; the new runs go there too"
|
|
98
|
+
option :concurrency, type: :numeric, aliases: "-c", desc: "Restarts in flight at once (default: the bench's)"
|
|
99
|
+
option :backend, enum: Environments::BACKENDS.keys, desc: "Sandbox backend (default: daytona)"
|
|
100
|
+
option :max_output_tokens, type: :numeric, banner: "TOKENS",
|
|
101
|
+
desc: "Cap the agent's output per model call (default: the provider's)"
|
|
102
|
+
option :recover, type: :boolean, default: false,
|
|
103
|
+
desc: "Continue the failed step's agent session from its saved history"
|
|
104
|
+
option :reverify, type: :boolean, default: false,
|
|
105
|
+
desc: "Grade the last verified step again (with the current tests) and go on from there"
|
|
106
|
+
option :allow_scored, type: :boolean, default: false, desc: "Restart a scored run (--reverify always may)"
|
|
107
|
+
def restart(*runs)
|
|
108
|
+
raise Thor::Error, "lemans: name the run(s) to restart" if runs.empty?
|
|
109
|
+
raise Thor::Error, "lemans: --recover and --reverify exclude each other" if options[:recover] && options[:reverify]
|
|
90
110
|
|
|
91
|
-
|
|
111
|
+
Miniswen.refresh_registry!
|
|
92
112
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
113
|
+
store = Stores::FS.new(options[:runs_dir], filterer: SecretsFilter.default)
|
|
114
|
+
ids = runs.map { File.basename(it) }.uniq
|
|
115
|
+
found = store.fetch.select { ids.include?(it.id) }.to_h { [ it.id, it ] }
|
|
116
|
+
missing = ids - found.keys
|
|
117
|
+
raise Thor::Error, "lemans: no run #{missing.join(", ")} under #{options[:runs_dir]}" if missing.any?
|
|
96
118
|
|
|
97
|
-
|
|
98
|
-
|
|
119
|
+
sources = found.values_at(*ids)
|
|
120
|
+
|
|
121
|
+
# The board lays out every model the runs used, as wide as their highest attempt
|
|
122
|
+
config = Config.load_file(options[:bench])
|
|
123
|
+
config.load_options(**options.transform_keys(&:to_sym), model: sources.map(&:model).uniq,
|
|
124
|
+
attempts: sources.filter_map(&:index).max)
|
|
125
|
+
|
|
126
|
+
tasks = filter_tasks(config.tasks, name: sources.map(&:task).uniq)
|
|
127
|
+
|
|
128
|
+
mode = (:recover if options[:recover]) || (:reverify if options[:reverify])
|
|
129
|
+
runner = Runner.new(config, tasks, store:, restarts: sources, restart_mode: mode, allow_scored: options[:allow_scored])
|
|
130
|
+
|
|
131
|
+
execute(runner, store, tasks)
|
|
99
132
|
rescue ConfigError => e
|
|
100
133
|
raise Thor::Error, "lemans: #{e.message}"
|
|
101
134
|
rescue Interrupt
|
|
102
135
|
say ""
|
|
103
136
|
exit 130
|
|
104
|
-
ensure
|
|
105
|
-
reporter&.stop
|
|
106
137
|
end
|
|
107
138
|
|
|
108
|
-
desc "clobber", "Delete run results"
|
|
109
|
-
option :runs_dir, default: "./runs", desc: "Directory holding run directories"
|
|
139
|
+
desc "clobber [RUNS_DIR]", "Delete run results"
|
|
140
|
+
option :runs_dir, default: "./runs", desc: "Directory holding run directories (or pass it as RUNS_DIR)"
|
|
110
141
|
option :task, desc: "Only these tasks' runs", repeatable: true
|
|
111
142
|
option :ttl, desc: "Only runs older than this (10m, 2h, 1d)"
|
|
112
143
|
option :invalid, type: :boolean, default: false, desc: "Only runs that measured nothing (invalid or unreadable)"
|
|
113
144
|
option :force, type: :boolean, default: false, aliases: "-f", desc: "Delete without asking"
|
|
114
|
-
def clobber
|
|
115
|
-
store = Stores::FS.new(
|
|
145
|
+
def clobber(runs_dir = options[:runs_dir])
|
|
146
|
+
store = Stores::FS.new(runs_dir)
|
|
116
147
|
clobber = Clobber.new(store, tasks: options[:task], ttl: options[:ttl], invalid: options[:invalid])
|
|
117
148
|
|
|
118
149
|
doomed = clobber.matches
|
|
119
|
-
return say "lemans: nothing to clobber under #{
|
|
150
|
+
return say "lemans: nothing to clobber under #{runs_dir}" if doomed.empty?
|
|
120
151
|
|
|
121
152
|
unless options[:force]
|
|
122
153
|
doomed.each { say it.id }
|
|
123
|
-
return say "lemans: nothing deleted" unless yes?("Delete #{doomed.size} run(s) under #{
|
|
154
|
+
return say "lemans: nothing deleted" unless yes?("Delete #{doomed.size} run(s) under #{runs_dir}? [y/N]")
|
|
124
155
|
end
|
|
125
156
|
|
|
126
157
|
removed = clobber.execute!
|
|
@@ -129,38 +160,44 @@ module Lemans
|
|
|
129
160
|
raise Thor::Error, "lemans: #{e.message}"
|
|
130
161
|
end
|
|
131
162
|
|
|
132
|
-
desc "regrade", "Re-grade stored results from their checks.json after a verification_test.rb grading change"
|
|
163
|
+
desc "regrade [RUNS_DIR]", "Re-grade stored results from their checks.json after a verification_test.rb grading change"
|
|
133
164
|
option :bench, default: ".", desc: "Directory holding bench.yml"
|
|
134
165
|
option :task, desc: "Re-grade these tasks' runs", repeatable: true, required: true
|
|
135
|
-
option :runs_dir, default: "./runs", desc: "Directory holding run directories"
|
|
166
|
+
option :runs_dir, default: "./runs", desc: "Directory holding run directories (or pass it as RUNS_DIR)"
|
|
136
167
|
option :mapping, banner: "PATH",
|
|
137
168
|
desc: "Grade by this checks.json-shaped file (every check `fail` or `fail (allowed)`, plus `grading`) " \
|
|
138
169
|
"instead of reading verification_test.rb"
|
|
139
|
-
def regrade
|
|
140
|
-
store = Stores::FS.new(
|
|
170
|
+
def regrade(runs_dir = options[:runs_dir])
|
|
171
|
+
store = Stores::FS.new(runs_dir)
|
|
141
172
|
tasks = filter_tasks(Config.load_file(options[:bench]).tasks, name: options[:task])
|
|
142
173
|
raise Thor::Error, "lemans: --mapping re-grades one task at a time" if options[:mapping] && tasks.size > 1
|
|
143
174
|
|
|
144
175
|
tasks.each do |task|
|
|
145
176
|
mapping = options[:mapping] ? Regrade.mapping_from_file(options[:mapping]) : Regrade.mapping_for(task)
|
|
146
177
|
regrade = Regrade.new(store, task.name, mapping:)
|
|
178
|
+
mapping.stray_features.to_a.each { say_status :warning, "#{it}: @feature comment outside any test", :yellow }
|
|
147
179
|
regrade.verify_mapping! unless options[:mapping]
|
|
148
180
|
|
|
149
181
|
changes, skipped = regrade.execute!
|
|
150
182
|
changes.each { say_status :regraded, "#{it.result.id} #{grade_change(it)}", :green }
|
|
151
183
|
skipped.each { |result, reason| say_status :skipped, "#{result.id} #{reason}", :yellow }
|
|
152
184
|
say "#{task.name}: #{changes.size} re-graded, #{skipped.size} skipped"
|
|
185
|
+
|
|
186
|
+
next unless task.multistep?
|
|
187
|
+
|
|
188
|
+
counted = regrade.record_total_steps!(task.steps)
|
|
189
|
+
say "#{task.name}: #{counted.size} run(s) gained total_steps: #{task.steps}" if counted.any?
|
|
153
190
|
end
|
|
154
191
|
|
|
155
192
|
say ""
|
|
156
|
-
say_status :report, "collecting results from #{
|
|
193
|
+
say_status :report, "collecting results from #{runs_dir}", :cyan
|
|
157
194
|
print_report Report.load(store, names: tasks.map(&:name))
|
|
158
195
|
rescue ConfigError => e
|
|
159
196
|
raise Thor::Error, "lemans: #{e.message}"
|
|
160
197
|
end
|
|
161
198
|
|
|
162
|
-
desc "report", "Summarize run results as a table or CSV"
|
|
163
|
-
option :runs_dir, default: "runs", desc: "Directory holding run directories"
|
|
199
|
+
desc "report [RUNS_DIR]", "Summarize run results as a table or CSV"
|
|
200
|
+
option :runs_dir, default: "runs", desc: "Directory holding run directories (or pass it as RUNS_DIR)"
|
|
164
201
|
option :tag, desc: "Only runs whose result carries this tag", repeatable: true
|
|
165
202
|
option :task, desc: "Only these tasks' runs", repeatable: true
|
|
166
203
|
option :metadata, banner: "KEY:VALUE", desc: "Only runs whose task metadata has this value (every pair must match)",
|
|
@@ -168,11 +205,17 @@ module Lemans
|
|
|
168
205
|
option :format, default: "table", enum: %w[table csv], desc: "Output format"
|
|
169
206
|
option :aggregate, aliases: "-A", banner: "COLUMNS", lazy_default: "task-model",
|
|
170
207
|
desc: "Group results by 1-3 dash-joined columns (task, agent, model)"
|
|
171
|
-
option :sort, aliases: "-S", banner: "
|
|
172
|
-
|
|
173
|
-
|
|
208
|
+
option :sort, aliases: "-S", banner: "COLUMNS",
|
|
209
|
+
desc: "Sort by dash-joined columns, e.g. score-credit (numbers high to low, names A-Z; ^column reverses)"
|
|
210
|
+
option :skip_invalid, type: :boolean, default: false, desc: "Leave out trials with an invalid outcome"
|
|
211
|
+
option :show_features, type: :boolean, default: false, desc: "Add a feat:<name> column per tracked feature"
|
|
212
|
+
option :hide_columns, banner: "COLUMNS", desc: "Leave dash-joined columns out of the table, e.g. steps-tokens-trial"
|
|
213
|
+
def report(runs_dir = options[:runs_dir])
|
|
214
|
+
store = Stores::FS.new(runs_dir)
|
|
174
215
|
results = Report.load(store, tags: options[:tag], names: options[:task],
|
|
175
|
-
metadata: Report.metadata_filter(options[:metadata])
|
|
216
|
+
metadata: Report.metadata_filter(options[:metadata]),
|
|
217
|
+
skip_invalid: options[:skip_invalid], show_features: options[:show_features],
|
|
218
|
+
hide_columns: options[:hide_columns])
|
|
176
219
|
raise Thor::Error, "lemans: no matching results found" if results.empty?
|
|
177
220
|
|
|
178
221
|
results = Report::Aggregate.new(results, keys: Report::Aggregate.keys(options[:aggregate])) if options[:aggregate]
|
|
@@ -184,6 +227,29 @@ module Lemans
|
|
|
184
227
|
|
|
185
228
|
private
|
|
186
229
|
|
|
230
|
+
def execute(runner, store, tasks)
|
|
231
|
+
reporter =
|
|
232
|
+
if interactive?
|
|
233
|
+
BoardReporter.new(tasks: tasks.map(&:name), models: runner.config.models,
|
|
234
|
+
attempts: runner.config.attempts, total: runner.attempts.size)
|
|
235
|
+
else
|
|
236
|
+
ProgressReporter.new(shell:, tasks: tasks.map(&:name))
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
reporter.start
|
|
240
|
+
|
|
241
|
+
summary = runner.run(reporter)
|
|
242
|
+
|
|
243
|
+
say ""
|
|
244
|
+
say_status :report, "collecting results from #{options[:runs_dir]}", :cyan
|
|
245
|
+
print_report Report.load(store)
|
|
246
|
+
|
|
247
|
+
exit 130 if summary.status == :interrupted
|
|
248
|
+
exit 1 if summary.status == :invalid
|
|
249
|
+
ensure
|
|
250
|
+
reporter&.stop
|
|
251
|
+
end
|
|
252
|
+
|
|
187
253
|
def filter_tasks(tasks, tags: nil, name: nil)
|
|
188
254
|
tasks = tasks.dup
|
|
189
255
|
|
|
@@ -199,9 +265,19 @@ module Lemans
|
|
|
199
265
|
end
|
|
200
266
|
|
|
201
267
|
def grade_change(change)
|
|
202
|
-
%i[reward credit].map { |grade| "#{grade} #{change[grade].map(&:inspect).join(" -> ")}" }
|
|
268
|
+
grades = %i[reward credit].map { |grade| "#{grade} #{change[grade].map(&:inspect).join(" -> ")}" }
|
|
269
|
+
before, after = change.features
|
|
270
|
+
return grades.join(" ") if before == after
|
|
271
|
+
|
|
272
|
+
features = (before.to_h.keys | after.to_h.keys).sort.filter_map do |name|
|
|
273
|
+
was, now = [ before, after ].map { feature_mark(it&.fetch(name, nil)) }
|
|
274
|
+
"#{name} #{was} -> #{now}" if was != now
|
|
275
|
+
end
|
|
276
|
+
[ *grades, "features #{features.join(", ")}" ].join(" ")
|
|
203
277
|
end
|
|
204
278
|
|
|
279
|
+
def feature_mark(passed) = { true => "✓", false => "✗" }.fetch(passed, "-")
|
|
280
|
+
|
|
205
281
|
def print_report(report)
|
|
206
282
|
print_table report.to_rows
|
|
207
283
|
color = report.summary[:invalid].positive? ? :red : nil
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# Allowlist proxy
|
|
2
|
+
|
|
3
|
+
This is an HTTP proxy that lets clients reach the allowed hosts only. The Docker backend runs it for the `allowlist` network mode.
|
|
4
|
+
|
|
5
|
+
The proxy supports two request types:
|
|
6
|
+
|
|
7
|
+
- `CONNECT host:port` tunnels (https).
|
|
8
|
+
- Absolute-form requests (`GET http://host/path`) for plain http.
|
|
9
|
+
|
|
10
|
+
The proxy refuses a host that is not on the list with `403 Forbidden`. Host names must match exactly. IP ranges are not supported.
|
|
11
|
+
|
|
12
|
+
## How the Docker backend uses it
|
|
13
|
+
|
|
14
|
+
1. Lemans puts the task container on an internal network. An internal network has no route out.
|
|
15
|
+
2. Lemans connects the proxy container to the internal network and to `bridge`.
|
|
16
|
+
3. Lemans sets `http_proxy` and `https_proxy` for each command in the task container.
|
|
17
|
+
|
|
18
|
+
A tool that ignores the proxy variables cannot reach the network.
|
|
19
|
+
|
|
20
|
+
When the allowed hosts change, lemans replaces the proxy container.
|
|
21
|
+
|
|
22
|
+
## Run it standalone
|
|
23
|
+
|
|
24
|
+
Build the image:
|
|
25
|
+
|
|
26
|
+
```sh
|
|
27
|
+
docker build --tag lemans-proxy lib/lemans/environments/docker/proxy
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Start the proxy. Give the allowed hosts as arguments:
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
docker run --rm --publish 3128:3128 lemans-proxy rubygems.org index.rubygems.org
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Send requests through it:
|
|
37
|
+
|
|
38
|
+
```sh
|
|
39
|
+
curl --proxy http://localhost:3128 https://rubygems.org # allowed
|
|
40
|
+
curl --proxy http://localhost:3128 https://example.com # 403 Forbidden
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
To run it without Docker, use Ruby 3.4 or later:
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
ruby lib/lemans/environments/docker/proxy/server.rb rubygems.org
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The `PORT` variable changes the listen port. The default port is 3128.
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "socket"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
# An HTTP proxy that lets clients reach the allowed hosts only:
|
|
7
|
+
# CONNECT tunnels for https, absolute-form requests for plain http.
|
|
8
|
+
class AllowlistProxy
|
|
9
|
+
HOP_HEADERS = /\A(?:connection|keep-alive|proxy-[\w-]+):/i
|
|
10
|
+
|
|
11
|
+
def initialize(hosts, port: 3128)
|
|
12
|
+
@hosts = hosts.map(&:downcase)
|
|
13
|
+
@port = port
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def run
|
|
17
|
+
server = TCPServer.new("0.0.0.0", @port)
|
|
18
|
+
$stdout.puts "listening on #{@port}: #{@hosts.join(", ")}"
|
|
19
|
+
$stdout.flush
|
|
20
|
+
Thread.report_on_exception = false
|
|
21
|
+
loop { Thread.new(server.accept) { handle(it) } }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def handle(client)
|
|
27
|
+
head = client.gets("\r\n\r\n") or return
|
|
28
|
+
request_line, *headers = head.split("\r\n")
|
|
29
|
+
verb, target, version = request_line.split(" ", 3)
|
|
30
|
+
|
|
31
|
+
verb == "CONNECT" ? tunnel(client, target) : forward(client, verb, target, version, headers)
|
|
32
|
+
rescue SystemCallError, IOError, SocketError, URI::Error
|
|
33
|
+
client.write("HTTP/1.1 502 Bad Gateway\r\n\r\n") rescue nil # rubocop:disable Style/RescueModifier
|
|
34
|
+
ensure
|
|
35
|
+
client.close
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def tunnel(client, target)
|
|
39
|
+
host, port = target.split(":", 2)
|
|
40
|
+
return deny(client) unless allowed?(host)
|
|
41
|
+
|
|
42
|
+
upstream = Socket.tcp(host, port.to_i, connect_timeout: 10)
|
|
43
|
+
client.write("HTTP/1.1 200 Connection Established\r\n\r\n")
|
|
44
|
+
pump(client, upstream)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# One request per connection: the upstream is told to close after the response.
|
|
48
|
+
def forward(client, verb, target, version, headers)
|
|
49
|
+
uri = URI(target)
|
|
50
|
+
return deny(client) unless uri.is_a?(URI::HTTP) && allowed?(uri.host)
|
|
51
|
+
|
|
52
|
+
upstream = Socket.tcp(uri.host, uri.port, connect_timeout: 10)
|
|
53
|
+
headers = headers.grep_v(HOP_HEADERS)
|
|
54
|
+
upstream.write("#{verb} #{uri.request_uri} #{version}\r\n#{headers.join("\r\n")}\r\nConnection: close\r\n\r\n")
|
|
55
|
+
pump(client, upstream)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def allowed?(host) = @hosts.include?(host.to_s.downcase)
|
|
59
|
+
|
|
60
|
+
def deny(client) = client.write("HTTP/1.1 403 Forbidden\r\n\r\n")
|
|
61
|
+
|
|
62
|
+
def pump(client, upstream)
|
|
63
|
+
Thread.new { copy(client, upstream) }
|
|
64
|
+
copy(upstream, client)
|
|
65
|
+
ensure
|
|
66
|
+
upstream.close
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# readpartial drains what gets already buffered, so a request body is not lost.
|
|
70
|
+
def copy(from, to)
|
|
71
|
+
loop { to.write(from.readpartial(65_536)) }
|
|
72
|
+
rescue EOFError, IOError, SystemCallError
|
|
73
|
+
nil
|
|
74
|
+
ensure
|
|
75
|
+
to.close_write rescue nil # rubocop:disable Style/RescueModifier
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
AllowlistProxy.new(ARGV, port: Integer(ENV.fetch("PORT", "3128"))).run if $PROGRAM_NAME == __FILE__
|