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.
@@ -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
- Mapping = Struct.new(:checks, :base_credit, :points, keyword_init: true) do
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
- new(checks:, base_credit: grading["base_credit"], points: allowed.to_h { [ it, declared.fetch(it, 1) ] })
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
- local, = task.test_files.find { |_, remote| remote == "verification_test.rb" }
101
- raise ConfigError, "#{task.name} has no verification_test.rb to read the grading from" unless local
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
- @results ||= store.query(task:).select(&:scored?).sort_by { it.id.to_s }
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
- return if reward == result.reward && credit == result.credit && JSON.parse(JSON.generate(updated)) == checks
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
- store.save(result.graded!(reward, credit:))
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!(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
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? ? METRICS : METRICS - [ :credit ]
50
- [ keys.map(&:to_s) + metrics.map(&:to_s) ] +
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
- keys.map { |key| display_key(key, group[key]) } + metrics.map { cell(it, group) }
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 { |group| csv << columns.map { group[it] } }
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(group.filter_map { it[:credit] }),
77
- duration: median(group.filter_map { it[:duration] }),
78
- cost_usd: mean(group.filter_map { it[:cost_usd] }),
79
- steps: mean(group.filter_map { it[:steps] }),
80
- tokens: mean(group.filter_map { it[:tokens] })
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 :time then time(group[:duration])
89
- when :cost then cost(group[:cost_usd])
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 mean_display(group[:tokens], 0)
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
@@ -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 outcome scored cost_usd steps tokens duration started_at trial
11
- tags detail].freeze
12
- TABLE_COLUMNS = %i[task agent model reward credit outcome cost_usd steps tokens duration trial].freeze
13
- NUMERIC_COLUMNS = %i[reward credit cost_usd steps tokens duration].freeze
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
+ # 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: validate the column name and keep
76
- # rows that never measured the value at the bottom.
77
- def sort_column(name, allowed:)
78
- column = name.to_s.to_sym
79
- return column if allowed.include?(column)
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 #{name.inspect} (try #{allowed.join(", ")})"
121
+ raise ConfigError, "--sort: unknown column in #{spec.inspect} (try #{allowed.join(", ")})"
82
122
  end
83
123
 
84
- def sort_rows(rows, descending: false)
85
- keyed = rows.map { [ yield(it), it ] }
86
- present, missing = keyed.partition { |value, _| value }
87
- sorted = present.sort_by { |value, _| value }
88
- sorted.reverse! if descending
89
- (sorted + missing).map(&:last)
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!(column)
105
- column = self.class.sort_column(column, allowed: TABLE_COLUMNS)
106
- @rows = self.class.sort_rows(rows, descending: NUMERIC_COLUMNS.include?(column)) { it[column] }
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 table_columns = fractional? ? TABLE_COLUMNS : TABLE_COLUMNS - [ :credit ]
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
- display(column == :model ? short_model(row[:model]) : row[column])
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 { |column| column == :tags ? Array(row[:tags]).join(" ") : row[column] }
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])}#{pass_at_k(group)}"
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 "-"