lemans 1.5.0 → 1.5.2
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 +14 -0
- data/README.md +86 -1
- data/exe/lemans-remote +64 -8
- data/exe/lemans-viewer +45 -9
- data/lib/lemans/agent.rb +5 -4
- data/lib/lemans/agents.rb +13 -5
- data/lib/lemans/cli/regrade.rb +29 -77
- data/lib/lemans/cli/report/aggregate.rb +61 -34
- data/lib/lemans/cli/report.rb +183 -29
- data/lib/lemans/cli.rb +53 -10
- data/lib/lemans/environments/daytona/retries.rb +2 -2
- data/lib/lemans/environments/daytona.rb +7 -1
- data/lib/lemans/environments/docker/proxy/README.md +1 -1
- data/lib/lemans/environments/docker/proxy/server.rb +2 -1
- data/lib/lemans/environments/docker.rb +5 -1
- data/lib/lemans/environments.rb +14 -4
- data/lib/lemans/result.rb +14 -6
- data/lib/lemans/stores/fs.rb +5 -4
- data/lib/lemans/trial/verifier/test_scanner.rb +136 -0
- data/lib/lemans/trial/verifier.rb +19 -10
- data/lib/lemans/trial.rb +2 -1
- data/lib/lemans/version.rb +1 -1
- data/lib/miniswen/agent.rb +20 -9
- data/lib/miniswen/ruby_llm.rb +13 -0
- data/lib/miniswen/version.rb +1 -1
- metadata +2 -1
data/lib/lemans/cli/regrade.rb
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
-
require "prism"
|
|
5
4
|
|
|
6
5
|
module Lemans
|
|
7
6
|
class CLI < Thor
|
|
@@ -12,13 +11,19 @@ module Lemans
|
|
|
12
11
|
ALLOWED = "fail (allowed)"
|
|
13
12
|
CHECKS = "checks.json"
|
|
14
13
|
|
|
15
|
-
|
|
14
|
+
# A checks.json-shaped file: `checks`, `grading`, and the features as
|
|
15
|
+
# `features: { name => [checks] }`.
|
|
16
|
+
Mapping = Struct.new(:checks, :base_credit, :points, :features, :stray_features, keyword_init: true) do
|
|
16
17
|
def self.from_json(data)
|
|
17
18
|
checks = data["checks"] or raise ConfigError, "a mapping needs a `checks` section"
|
|
18
19
|
grading = data["grading"] || {}
|
|
19
20
|
declared = grading["points"] || {}
|
|
20
21
|
allowed = checks.select { |_, status| status == ALLOWED }.keys
|
|
21
|
-
|
|
22
|
+
features = data["features"] || {}
|
|
23
|
+
unknown = features.values.flatten - checks.keys
|
|
24
|
+
raise ConfigError, "the mapping's features name unknown checks: #{unknown.inspect}" if unknown.any?
|
|
25
|
+
|
|
26
|
+
new(checks:, base_credit: grading["base_credit"], points: allowed.to_h { [ it, declared.fetch(it, 1) ] }, features:)
|
|
22
27
|
end
|
|
23
28
|
|
|
24
29
|
def names = checks.keys.sort
|
|
@@ -28,82 +33,18 @@ module Lemans
|
|
|
28
33
|
def grading = { base_credit:, points: }.compact
|
|
29
34
|
end
|
|
30
35
|
|
|
31
|
-
Change = Struct.new(:result, :reward, :credit, keyword_init: true)
|
|
32
|
-
|
|
33
|
-
# Reads the grading schema off the test file without running it: every
|
|
34
|
-
# `def test_*` and ActiveSupport `test "..."` is a check, an
|
|
35
|
-
# `allow_failure` call inside makes it an extra worth its `points:`.
|
|
36
|
-
class TestScanner < Prism::Visitor
|
|
37
|
-
attr_reader :tests, :points, :base_credit
|
|
38
|
-
|
|
39
|
-
def initialize
|
|
40
|
-
super
|
|
41
|
-
@scope = []
|
|
42
|
-
@tests = []
|
|
43
|
-
@points = {}
|
|
44
|
-
@current = nil
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
def visit_module_node(node) = scoped(node) { super }
|
|
48
|
-
|
|
49
|
-
def visit_class_node(node) = scoped(node) { super }
|
|
50
|
-
|
|
51
|
-
def visit_def_node(node)
|
|
52
|
-
return super unless node.name.start_with?("test_")
|
|
53
|
-
|
|
54
|
-
within("#{@scope.join("::")}##{node.name}") { super }
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
def visit_call_node(node)
|
|
58
|
-
case node.name
|
|
59
|
-
when :test
|
|
60
|
-
title = node.arguments&.arguments&.first
|
|
61
|
-
if node.receiver.nil? && node.block && title.is_a?(Prism::StringNode)
|
|
62
|
-
return within("#{@scope.join("::")}#test_#{title.unescaped.gsub(/\s+/, "_")}") { super }
|
|
63
|
-
end
|
|
64
|
-
when :allow_failure
|
|
65
|
-
@points[@current] ||= points_of(node) if @current
|
|
66
|
-
when :base_credit=
|
|
67
|
-
@base_credit = node.arguments.arguments.first.value if node.receiver.is_a?(Prism::ConstantReadNode) && node.receiver.name == :LemansReport
|
|
68
|
-
end
|
|
69
|
-
super
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
private
|
|
73
|
-
|
|
74
|
-
def scoped(node)
|
|
75
|
-
@scope.push(node.constant_path.full_name)
|
|
76
|
-
yield
|
|
77
|
-
ensure
|
|
78
|
-
@scope.pop
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
def within(check)
|
|
82
|
-
@tests << check
|
|
83
|
-
@current = check
|
|
84
|
-
yield
|
|
85
|
-
ensure
|
|
86
|
-
@current = nil
|
|
87
|
-
end
|
|
88
|
-
|
|
89
|
-
def points_of(node)
|
|
90
|
-
keywords = node.arguments&.arguments&.grep(Prism::KeywordHashNode)&.first
|
|
91
|
-
pair = keywords&.elements&.find { it.is_a?(Prism::AssocNode) && it.key.is_a?(Prism::SymbolNode) && it.key.unescaped == "points" }
|
|
92
|
-
pair ? pair.value.value : 1
|
|
93
|
-
end
|
|
94
|
-
end
|
|
36
|
+
Change = Struct.new(:result, :reward, :credit, :features, keyword_init: true)
|
|
95
37
|
|
|
96
38
|
class << self
|
|
97
39
|
def mapping_from_file(path) = Mapping.from_json(JSON.parse(File.read(path)))
|
|
98
40
|
|
|
99
41
|
def mapping_for(task)
|
|
100
|
-
|
|
101
|
-
raise ConfigError, "#{task.name} has no verification_test.rb to read the grading from" unless
|
|
42
|
+
scanner = Trial::Verifier::TestScanner.for(task)
|
|
43
|
+
raise ConfigError, "#{task.name} has no verification_test.rb to read the grading from" unless scanner
|
|
102
44
|
|
|
103
|
-
scanner = TestScanner.new
|
|
104
|
-
Prism.parse_file(local.to_s).value.accept(scanner)
|
|
105
45
|
checks = scanner.tests.to_h { [ it, scanner.points.key?(it) ? ALLOWED : "fail" ] }
|
|
106
|
-
Mapping.new(checks:, base_credit: scanner.base_credit, points: scanner.points
|
|
46
|
+
Mapping.new(checks:, base_credit: scanner.base_credit, points: scanner.points,
|
|
47
|
+
features: scanner.features, stray_features: scanner.stray_features)
|
|
107
48
|
end
|
|
108
49
|
end
|
|
109
50
|
|
|
@@ -115,8 +56,14 @@ module Lemans
|
|
|
115
56
|
@mapping = mapping
|
|
116
57
|
end
|
|
117
58
|
|
|
118
|
-
def results
|
|
119
|
-
|
|
59
|
+
def results = runs.select(&:scored?)
|
|
60
|
+
|
|
61
|
+
# Older multistep results lack the task's step count; returns the runs that gained it.
|
|
62
|
+
def record_total_steps!(total)
|
|
63
|
+
runs.select { it.steps && it.total_steps != total }.each do |result|
|
|
64
|
+
result.total_steps = total
|
|
65
|
+
store.save(result)
|
|
66
|
+
end
|
|
120
67
|
end
|
|
121
68
|
|
|
122
69
|
# A statically read mapping is only trusted once a stored checks.json
|
|
@@ -149,6 +96,8 @@ module Lemans
|
|
|
149
96
|
|
|
150
97
|
private
|
|
151
98
|
|
|
99
|
+
def runs = @runs ||= store.query(task:).sort_by { it.id.to_s }
|
|
100
|
+
|
|
152
101
|
def checks_of(result)
|
|
153
102
|
raw = store.read_artifact(result, CHECKS)
|
|
154
103
|
raw && JSON.parse(raw)
|
|
@@ -164,11 +113,14 @@ module Lemans
|
|
|
164
113
|
updated[:grading] = mapping.grading unless mapping.grading.empty?
|
|
165
114
|
reward = failures.empty? ? 1.0 : 0.0
|
|
166
115
|
credit = credit_of(statuses, reward)
|
|
167
|
-
|
|
116
|
+
features = Trial::Verifier.features_from(statuses, mapping.features)
|
|
117
|
+
return if reward == result.reward && credit == result.credit && features == result.features &&
|
|
118
|
+
JSON.parse(JSON.generate(updated)) == checks
|
|
168
119
|
|
|
169
120
|
store.save_artifact(result, "#{JSON.pretty_generate(updated)}\n", path: CHECKS, force: true)
|
|
170
|
-
change = Change.new(result:, reward: [ result.reward, reward ], credit: [ result.credit, credit ]
|
|
171
|
-
|
|
121
|
+
change = Change.new(result:, reward: [ result.reward, reward ], credit: [ result.credit, credit ],
|
|
122
|
+
features: [ result.features, features ])
|
|
123
|
+
store.save(result.graded!(reward, credit:, features:))
|
|
172
124
|
change
|
|
173
125
|
end
|
|
174
126
|
|
|
@@ -10,7 +10,7 @@ module Lemans
|
|
|
10
10
|
# task, agent, model — "task-model" reads as two columns.
|
|
11
11
|
class Aggregate
|
|
12
12
|
KEYS = %i[task agent model].freeze
|
|
13
|
-
METRICS = %i[score credit time cost steps tokens].freeze
|
|
13
|
+
METRICS = %i[score credit features time cost steps tokens].freeze
|
|
14
14
|
METRIC_SOURCES = { credit: :credit, time: :duration, cost: :cost_usd, steps: :steps, tokens: :tokens }.freeze
|
|
15
15
|
|
|
16
16
|
attr_reader :report, :keys
|
|
@@ -32,32 +32,36 @@ module Lemans
|
|
|
32
32
|
.sort_by { |group| keys.map { group[it].to_s } }
|
|
33
33
|
end
|
|
34
34
|
|
|
35
|
-
def order_by!(
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
35
|
+
def order_by!(spec)
|
|
36
|
+
sort_keys = Report.sort_columns(spec, allowed: keys + METRICS + report.feature_columns).map do |column, reversed|
|
|
37
|
+
[ ->(group) { sort_value(group, column) }, !keys.include?(column) != reversed ]
|
|
38
|
+
end
|
|
39
|
+
@groups = Report.sort_rows(@groups, sort_keys)
|
|
45
40
|
self
|
|
46
41
|
end
|
|
47
42
|
|
|
48
43
|
def to_rows
|
|
49
|
-
metrics = report.fractional?
|
|
50
|
-
|
|
44
|
+
metrics = METRICS - [ (:credit unless report.fractional?), (:features unless report.features?) ].compact
|
|
45
|
+
columns = keys + metrics + report.shown_feature_columns
|
|
46
|
+
columns -= Report.hidden_columns(report.hide_columns, allowed: columns)
|
|
47
|
+
[ columns.map(&:to_s) ] +
|
|
51
48
|
@groups.map do |group|
|
|
52
|
-
|
|
49
|
+
columns.map do |column|
|
|
50
|
+
if keys.include?(column) then display_key(column, group[column])
|
|
51
|
+
elsif metrics.include?(column) then cell(column, group)
|
|
52
|
+
else pass_rate(group[column])
|
|
53
|
+
end
|
|
54
|
+
end
|
|
53
55
|
end
|
|
54
56
|
end
|
|
55
57
|
|
|
56
58
|
def to_csv
|
|
57
|
-
columns = keys + %i[solved attempts credit duration cost_usd steps tokens]
|
|
59
|
+
columns = keys + %i[solved attempts credit features_passed features_total duration cost_usd steps tokens]
|
|
58
60
|
CSV.generate do |csv|
|
|
59
|
-
csv << columns
|
|
60
|
-
@groups.each
|
|
61
|
+
csv << columns + report.shown_feature_columns
|
|
62
|
+
@groups.each do |group|
|
|
63
|
+
csv << columns.map { group[it] } + report.shown_feature_columns.map { group[it] && pass_rate(group[it]) }
|
|
64
|
+
end
|
|
61
65
|
end
|
|
62
66
|
end
|
|
63
67
|
|
|
@@ -67,33 +71,65 @@ module Lemans
|
|
|
67
71
|
|
|
68
72
|
private
|
|
69
73
|
|
|
74
|
+
def sort_value(group, column)
|
|
75
|
+
if column == :model then Report.short_model(group[:model])
|
|
76
|
+
elsif keys.include?(column) then group[column].to_s
|
|
77
|
+
elsif report.feature_columns.include?(column) then group[column] && Rational(*group[column])
|
|
78
|
+
elsif column == :features then group[:features_total] && Rational(group[:features_passed], group[:features_total])
|
|
79
|
+
elsif column == :score then [ Rational(group[:solved], group[:attempts]), group[:attempts] ]
|
|
80
|
+
else group[METRIC_SOURCES.fetch(column)]
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
|
|
70
84
|
# Attempts count every run; means and the median skip runs that never
|
|
71
85
|
# measured the value, so one invalid trial cannot zero out a cell.
|
|
72
86
|
def build(values, group)
|
|
87
|
+
tasks = group.group_by { it[:task] }.values
|
|
73
88
|
keys.zip(values).to_h.merge(
|
|
74
89
|
solved: Report.tally(group)[:solved],
|
|
75
90
|
attempts: group.size,
|
|
76
|
-
credit: mean(
|
|
77
|
-
duration: median(
|
|
78
|
-
cost_usd: mean(
|
|
79
|
-
steps: mean(
|
|
80
|
-
tokens: mean(
|
|
91
|
+
credit: mean(task_means(tasks, :credit)),
|
|
92
|
+
duration: median(task_means(tasks, :duration)),
|
|
93
|
+
cost_usd: mean(task_means(tasks, :cost_usd)),
|
|
94
|
+
steps: mean(task_means(tasks, :steps)),
|
|
95
|
+
tokens: mean(task_means(tasks, :tokens)),
|
|
96
|
+
**features_sum(group),
|
|
97
|
+
**report.feature_columns.to_h { [ it, feature_tally(group, it) ] }
|
|
81
98
|
)
|
|
82
99
|
end
|
|
83
100
|
|
|
101
|
+
# Passed out of the runs that graded the feature; nil when none did
|
|
102
|
+
def feature_tally(group, column)
|
|
103
|
+
graded = group.map { Report.feature_of(it, column) }.reject(&:nil?)
|
|
104
|
+
[ graded.count(true), graded.size ] unless graded.empty?
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def pass_rate(tally) = tally ? tally.join("/") : "-"
|
|
108
|
+
|
|
109
|
+
# Features passed out of graded, summed over the group's runs
|
|
110
|
+
def features_sum(group)
|
|
111
|
+
tallies = group.filter_map { Report.features_tally(it) }
|
|
112
|
+
return { features_passed: nil, features_total: nil } if tallies.empty?
|
|
113
|
+
|
|
114
|
+
{ features_passed: tallies.sum(&:first), features_total: tallies.sum(&:last) }
|
|
115
|
+
end
|
|
116
|
+
|
|
84
117
|
def cell(metric, group)
|
|
85
118
|
case metric
|
|
86
119
|
when :score then "#{group[:solved]}/#{group[:attempts]}"
|
|
87
120
|
when :credit then mean_display(group[:credit], 2)
|
|
88
|
-
when :
|
|
89
|
-
when :
|
|
121
|
+
when :features then group[:features_total] ? "#{group[:features_passed]}/#{group[:features_total]}" : "-"
|
|
122
|
+
when :time then Report.duration_display(group[:duration])
|
|
123
|
+
when :cost then Report.cost_display(group[:cost_usd])
|
|
90
124
|
when :steps then mean_display(group[:steps], 1)
|
|
91
|
-
when :tokens then
|
|
125
|
+
when :tokens then Report.tokens_display(group[:tokens])
|
|
92
126
|
end
|
|
93
127
|
end
|
|
94
128
|
|
|
95
129
|
def mean(values) = values.empty? ? nil : values.sum(0.0) / values.size
|
|
96
130
|
|
|
131
|
+
def task_means(tasks, metric) = tasks.filter_map { |rows| mean(rows.filter_map { it[metric] }) }
|
|
132
|
+
|
|
97
133
|
def median(values)
|
|
98
134
|
return nil if values.empty?
|
|
99
135
|
|
|
@@ -108,15 +144,6 @@ module Lemans
|
|
|
108
144
|
key == :model ? Report.short_model(value) : value.to_s
|
|
109
145
|
end
|
|
110
146
|
|
|
111
|
-
def time(sec)
|
|
112
|
-
return "-" if sec.nil?
|
|
113
|
-
|
|
114
|
-
minutes, seconds = sec.round.divmod(60)
|
|
115
|
-
minutes.positive? ? "#{minutes}m #{seconds}s" : "#{seconds}s"
|
|
116
|
-
end
|
|
117
|
-
|
|
118
|
-
def cost(value) = value.nil? ? "-" : "$#{format("%g", value.round(4))}"
|
|
119
|
-
|
|
120
147
|
def mean_display(value, digits) = value.nil? ? "-" : format("%g", value.round(digits))
|
|
121
148
|
end
|
|
122
149
|
end
|
data/lib/lemans/cli/report.rb
CHANGED
|
@@ -7,18 +7,22 @@ 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
|
-
|
|
19
|
+
# Models match by full id or by the short name the table shows
|
|
20
|
+
def load(store, tags: nil, names: nil, models: nil, metadata: nil, skip_invalid: false, **)
|
|
19
21
|
rows = store.query(task: names, tags:, metadata:).map { row_from(it) }
|
|
22
|
+
rows = rows.select { models.intersect?([ it[:model], short_model(it[:model]) ]) } if models
|
|
23
|
+
rows = rows.select { it[:scored] } if skip_invalid
|
|
20
24
|
new(rows.sort_by { [ it[:task].to_s, it[:started_at].to_s, it[:trial].to_s ] },
|
|
21
|
-
unreadable: store.unreadable.size)
|
|
25
|
+
unreadable: store.unreadable.size, **)
|
|
22
26
|
end
|
|
23
27
|
|
|
24
28
|
def row_from(result)
|
|
@@ -29,6 +33,9 @@ module Lemans
|
|
|
29
33
|
model: result.model,
|
|
30
34
|
reward: result.reward,
|
|
31
35
|
credit: result.credit,
|
|
36
|
+
features: result.features,
|
|
37
|
+
completed_steps: result.steps&.size,
|
|
38
|
+
total_steps: result.total_steps,
|
|
32
39
|
outcome: result.status,
|
|
33
40
|
scored: result.scored?,
|
|
34
41
|
detail: result.detail,
|
|
@@ -44,6 +51,38 @@ module Lemans
|
|
|
44
51
|
}
|
|
45
52
|
end
|
|
46
53
|
|
|
54
|
+
# Steps completed out of the task's steps; unknown for older runs halted midway
|
|
55
|
+
def progress_ratio(row) = row[:total_steps] && Rational(row[:completed_steps], row[:total_steps])
|
|
56
|
+
|
|
57
|
+
# Feature columns carry a prefix no regular column has
|
|
58
|
+
def feature_column(name) = :"feat:#{name}"
|
|
59
|
+
|
|
60
|
+
def feature_of(row, column) = row[:features]&.[](column.to_s.delete_prefix("feat:"))
|
|
61
|
+
|
|
62
|
+
# Features passed and graded in a run, nil when it graded none
|
|
63
|
+
def features_tally(row)
|
|
64
|
+
features = row[:features]
|
|
65
|
+
[ features.count { |_, passed| passed }, features.size ] if features && !features.empty?
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def duration_display(sec)
|
|
69
|
+
return "-" if sec.nil?
|
|
70
|
+
|
|
71
|
+
minutes, seconds = sec.round.divmod(60)
|
|
72
|
+
minutes.positive? ? "#{minutes}m #{seconds}s" : "#{seconds}s"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def cost_display(value) = value.nil? ? "-" : "$#{format("%g", value.round(4))}"
|
|
76
|
+
|
|
77
|
+
def tokens_display(value)
|
|
78
|
+
return "-" if value.nil?
|
|
79
|
+
|
|
80
|
+
[ [ 1e9, "B" ], [ 1e6, "M" ], [ 1e3, "K" ] ].each do |unit, suffix|
|
|
81
|
+
return format("%.1f#{suffix}", value / unit) if value >= unit
|
|
82
|
+
end
|
|
83
|
+
value.round.to_s
|
|
84
|
+
end
|
|
85
|
+
|
|
47
86
|
# A bench may name no model at all (nop, oracle); the summary needs a
|
|
48
87
|
# label, not a nil for ljust to crash on.
|
|
49
88
|
def short_model(model) = model.to_s.split("/").last || "(default)"
|
|
@@ -72,27 +111,63 @@ module Lemans
|
|
|
72
111
|
end
|
|
73
112
|
end
|
|
74
113
|
|
|
75
|
-
# One sorting rule for every view
|
|
76
|
-
#
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
114
|
+
# One sorting rule for every view. `score-credit` sorts by score, then
|
|
115
|
+
# credit; `^` reverses a column's natural order. Column names may hold
|
|
116
|
+
# dashes (features), so the longest known name wins.
|
|
117
|
+
def sort_columns(spec, allowed:)
|
|
118
|
+
columns, unknown = split_columns(spec, allowed:)
|
|
119
|
+
return columns if unknown.empty? && columns.any?
|
|
80
120
|
|
|
81
|
-
raise ConfigError, "--sort: unknown column #{
|
|
121
|
+
raise ConfigError, "--sort: unknown column in #{spec.inspect} (try #{allowed.join(", ")})"
|
|
82
122
|
end
|
|
83
123
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
124
|
+
# `--hide-columns steps-tokens`: names nobody knows are let go
|
|
125
|
+
def hidden_columns(spec, allowed:) = split_columns(spec, allowed:).first.map(&:first)
|
|
126
|
+
|
|
127
|
+
# [[column, reversed], ...] and the parts that name no column
|
|
128
|
+
def split_columns(spec, allowed:)
|
|
129
|
+
names = allowed.map(&:to_s).sort_by { -it.length }
|
|
130
|
+
rest = spec.to_s
|
|
131
|
+
columns = []
|
|
132
|
+
unknown = []
|
|
133
|
+
until rest.empty?
|
|
134
|
+
reversed = rest.start_with?("^")
|
|
135
|
+
rest = rest.delete_prefix("^")
|
|
136
|
+
if (name = names.find { rest == it || rest.start_with?("#{it}-") })
|
|
137
|
+
columns << [ name.to_sym, reversed ]
|
|
138
|
+
else
|
|
139
|
+
unknown << (name = rest[/\A[^-]*/])
|
|
140
|
+
end
|
|
141
|
+
rest = rest.delete_prefix(name).delete_prefix("-")
|
|
142
|
+
end
|
|
143
|
+
[ columns, unknown ]
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Keys are [value, descending] pairs, tried in order; rows that never
|
|
147
|
+
# measured a value sink below the rest, and ties keep their order.
|
|
148
|
+
def sort_rows(rows, keys)
|
|
149
|
+
keyed = rows.each_with_index.map { |row, index| [ keys.map { |value, _| value.call(row) }, index, row ] }
|
|
150
|
+
keyed.sort { |(a, i, _), (b, j, _)| compare(a, b, keys.map(&:last)).nonzero? || i <=> j }.map(&:last)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
private def compare(values, others, descending)
|
|
154
|
+
values.zip(others, descending).each do |value, other, desc|
|
|
155
|
+
next if value == other
|
|
156
|
+
return 1 if value.nil?
|
|
157
|
+
return -1 if other.nil?
|
|
158
|
+
|
|
159
|
+
order = value <=> other
|
|
160
|
+
return desc ? -order : order unless order.zero?
|
|
161
|
+
end
|
|
162
|
+
0
|
|
90
163
|
end
|
|
91
164
|
end
|
|
92
165
|
|
|
93
|
-
def initialize(rows, unreadable: 0)
|
|
94
|
-
@rows = rows
|
|
166
|
+
def initialize(rows, unreadable: 0, show_features: false, hide_columns: nil)
|
|
167
|
+
@rows = with_total_steps(rows)
|
|
95
168
|
@unreadable = unreadable
|
|
169
|
+
@show_features = show_features
|
|
170
|
+
@hide_columns = hide_columns
|
|
96
171
|
end
|
|
97
172
|
|
|
98
173
|
# A store holding only unreadable results is not empty: the report's
|
|
@@ -101,9 +176,12 @@ module Lemans
|
|
|
101
176
|
|
|
102
177
|
# Numbers rank best-first the way a leaderboard reads; names sort A-Z.
|
|
103
178
|
# Trials that never measured the column sink to the bottom either way.
|
|
104
|
-
def order_by!(
|
|
105
|
-
|
|
106
|
-
|
|
179
|
+
def order_by!(spec)
|
|
180
|
+
keys = self.class.sort_columns(spec, allowed: TABLE_COLUMNS + feature_columns).map do |column, reversed|
|
|
181
|
+
numeric = NUMERIC_COLUMNS.include?(column) || feature_columns.include?(column)
|
|
182
|
+
[ ->(row) { sort_value(row, column) }, numeric != reversed ]
|
|
183
|
+
end
|
|
184
|
+
@rows = self.class.sort_rows(rows, keys)
|
|
107
185
|
self
|
|
108
186
|
end
|
|
109
187
|
|
|
@@ -113,13 +191,42 @@ module Lemans
|
|
|
113
191
|
|
|
114
192
|
def fractional? = rows.any? { it[:credit] && it[:credit] != it[:reward] }
|
|
115
193
|
|
|
116
|
-
def
|
|
194
|
+
def multistep? = rows.any? { it[:completed_steps] }
|
|
195
|
+
|
|
196
|
+
def features? = rows.any? { self.class.features_tally(it) }
|
|
197
|
+
|
|
198
|
+
# The features every task in view tracks; tasks with no graded run yet have no say.
|
|
199
|
+
# Shown one column each only on request.
|
|
200
|
+
def feature_columns
|
|
201
|
+
@feature_columns ||= rows.select { it[:features] }
|
|
202
|
+
.group_by { it[:task] }
|
|
203
|
+
.map { |_, group| group.flat_map { it[:features].keys }.uniq }
|
|
204
|
+
.reduce(:&).to_a.sort.map { self.class.feature_column(it) }
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
def shown_feature_columns = show_features ? feature_columns : []
|
|
208
|
+
|
|
209
|
+
def table_columns
|
|
210
|
+
columns = TABLE_COLUMNS - [ (:credit unless fractional?), (:progress unless multistep?),
|
|
211
|
+
(:features unless features?) ].compact
|
|
212
|
+
columns.insert(columns.index(:trial), *shown_feature_columns)
|
|
213
|
+
columns - self.class.hidden_columns(hide_columns, allowed: columns)
|
|
214
|
+
end
|
|
117
215
|
|
|
118
216
|
def to_rows
|
|
119
217
|
[ table_columns.map(&:to_s) ] +
|
|
120
218
|
rows.map do |row|
|
|
121
219
|
table_columns.map do |column|
|
|
122
|
-
|
|
220
|
+
case column
|
|
221
|
+
when :model then display(short_model(row[:model]))
|
|
222
|
+
when :progress then progress(row)
|
|
223
|
+
when :duration then self.class.duration_display(row[:duration])
|
|
224
|
+
when :cost_usd then self.class.cost_display(row[:cost_usd])
|
|
225
|
+
when :tokens then self.class.tokens_display(row[:tokens])
|
|
226
|
+
when :features then self.class.features_tally(row)&.join("/") || "-"
|
|
227
|
+
when *feature_columns then { true => "✓", false => "✗" }.fetch(self.class.feature_of(row, column), "-")
|
|
228
|
+
else display(row[column])
|
|
229
|
+
end
|
|
123
230
|
end
|
|
124
231
|
end
|
|
125
232
|
end
|
|
@@ -140,21 +247,66 @@ module Lemans
|
|
|
140
247
|
|
|
141
248
|
def to_csv
|
|
142
249
|
CSV.generate do |csv|
|
|
143
|
-
csv << COLUMNS
|
|
250
|
+
csv << COLUMNS + shown_feature_columns
|
|
144
251
|
rows.each do |row|
|
|
145
|
-
csv << COLUMNS.map {
|
|
252
|
+
csv << COLUMNS.map { csv_value(row, it) } +
|
|
253
|
+
shown_feature_columns.map { self.class.feature_of(row, it) }
|
|
146
254
|
end
|
|
147
255
|
end
|
|
148
256
|
end
|
|
149
257
|
|
|
150
258
|
private
|
|
151
259
|
|
|
260
|
+
def csv_value(row, column)
|
|
261
|
+
case column
|
|
262
|
+
when :tags then Array(row[:tags]).join(" ")
|
|
263
|
+
when :features_passed then self.class.features_tally(row)&.first
|
|
264
|
+
when :features_total then self.class.features_tally(row)&.last
|
|
265
|
+
else row[column]
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
def sort_value(row, column)
|
|
270
|
+
if column == :model then short_model(row[:model])
|
|
271
|
+
elsif column == :progress then self.class.progress_ratio(row)
|
|
272
|
+
elsif column == :features then self.class.features_tally(row)&.then { Rational(*it) }
|
|
273
|
+
elsif feature_columns.include?(column) then { true => 1, false => 0 }[self.class.feature_of(row, column)]
|
|
274
|
+
else row[column]
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# Older multistep results lack the task's step count: a run of the same
|
|
279
|
+
# task that solved it went through every step.
|
|
280
|
+
def with_total_steps(rows)
|
|
281
|
+
known = rows.filter_map do |row|
|
|
282
|
+
total = row[:total_steps] || (row[:completed_steps] if row[:reward].to_f >= 1.0)
|
|
283
|
+
[ row[:task], total ] if total
|
|
284
|
+
end.to_h
|
|
285
|
+
rows.map do |row|
|
|
286
|
+
next row if row[:total_steps] || !row[:completed_steps] || !known[row[:task]]
|
|
287
|
+
|
|
288
|
+
row.merge(total_steps: known[row[:task]])
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
|
|
152
292
|
# The rank divides solved by scored, not total: invalid trials measured nothing.
|
|
153
293
|
def stats(group)
|
|
154
294
|
totals = self.class.tally(group).merge(cost_usd: group.sum { it[:cost_usd].to_f })
|
|
155
295
|
rank = totals[:scored].positive? ? " (#{(100.0 * totals[:solved] / totals[:scored]).round}%)" : ""
|
|
156
296
|
"#{totals[:total]} trials: #{totals[:scored]} scored, #{totals[:invalid]} invalid, " \
|
|
157
|
-
"#{totals[:solved]} solved#{rank} · $#{format("%.4f", totals[:cost_usd])}
|
|
297
|
+
"#{totals[:solved]} solved#{rank}#{mean_credit(group)} · $#{format("%.4f", totals[:cost_usd])}" \
|
|
298
|
+
"#{pass_at_k(group)}"
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
# Each task weighs the same, as in the aggregate view
|
|
302
|
+
def mean_credit(group)
|
|
303
|
+
return "" unless fractional?
|
|
304
|
+
|
|
305
|
+
means = group.group_by { it[:task] }.values.filter_map do |rows|
|
|
306
|
+
credits = rows.filter_map { it[:credit] }
|
|
307
|
+
credits.sum(0.0) / credits.size unless credits.empty?
|
|
308
|
+
end
|
|
309
|
+
means.empty? ? "" : " · credit #{format("%.2f", means.sum / means.size)}"
|
|
158
310
|
end
|
|
159
311
|
|
|
160
312
|
def pass_at_k(group)
|
|
@@ -169,6 +321,8 @@ module Lemans
|
|
|
169
321
|
|
|
170
322
|
def short_model(model) = self.class.short_model(model)
|
|
171
323
|
|
|
324
|
+
def progress(row) = row[:completed_steps] ? "#{row[:completed_steps]}/#{row[:total_steps] || "?"}" : "-"
|
|
325
|
+
|
|
172
326
|
def display(value)
|
|
173
327
|
case value
|
|
174
328
|
when nil then "-"
|