lemans 0.2.2 → 1.0.0.pre.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +30 -10
  3. data/exe/lemans-remote +161 -46
  4. data/lib/lemans/agent.rb +39 -0
  5. data/lib/lemans/agents/miniswen.rb +28 -25
  6. data/lib/lemans/agents/miniswen_installed.rb +13 -9
  7. data/lib/lemans/agents/nop.rb +3 -3
  8. data/lib/lemans/agents/oracle.rb +8 -8
  9. data/lib/lemans/cli/board_reporter.rb +16 -16
  10. data/lib/lemans/cli/progress_reporter.rb +19 -19
  11. data/lib/lemans/cli/report/aggregate.rb +117 -0
  12. data/lib/lemans/cli/report.rb +164 -0
  13. data/lib/lemans/cli.rb +58 -63
  14. data/lib/lemans/clobber.rb +17 -55
  15. data/lib/lemans/config/agent.rb +51 -0
  16. data/lib/lemans/config/conversion.rb +62 -0
  17. data/lib/lemans/config/environment.rb +39 -0
  18. data/lib/lemans/config/image_spec.rb +42 -0
  19. data/lib/lemans/config/network_policy.rb +55 -0
  20. data/lib/lemans/config/revision.rb +42 -0
  21. data/lib/lemans/config/setup.rb +57 -0
  22. data/lib/lemans/config/tree_digest.rb +26 -0
  23. data/lib/lemans/config/verifier.rb +72 -0
  24. data/lib/lemans/config.rb +107 -0
  25. data/lib/lemans/environment.rb +54 -0
  26. data/lib/lemans/environments/daytona/faraday_transfer.rb +172 -0
  27. data/lib/lemans/environments/daytona/sdk_tweaks.rb +1 -1
  28. data/lib/lemans/environments/daytona/shell.rb +1 -1
  29. data/lib/lemans/environments/daytona/snapshot_store.rb +11 -11
  30. data/lib/lemans/environments/daytona.rb +21 -35
  31. data/lib/lemans/result.rb +270 -0
  32. data/lib/lemans/runner/executor.rb +64 -0
  33. data/lib/lemans/runner/task.rb +62 -0
  34. data/lib/lemans/runner.rb +82 -0
  35. data/lib/lemans/store.rb +44 -0
  36. data/lib/lemans/stores/fs.rb +122 -0
  37. data/lib/lemans/task_definition.rb +194 -0
  38. data/lib/lemans/trial/patch.rb +76 -0
  39. data/lib/lemans/trial/setup.rb +66 -0
  40. data/lib/lemans/trial/snapshot.rb +57 -0
  41. data/lib/lemans/trial/verifier.rb +190 -0
  42. data/lib/lemans/trial.rb +113 -147
  43. data/lib/lemans/version.rb +1 -1
  44. data/lib/lemans.rb +3 -2
  45. data/lib/miniswen/trajectory.rb +2 -0
  46. metadata +58 -25
  47. data/lib/lemans/agents/base.rb +0 -30
  48. data/lib/lemans/bench.rb +0 -280
  49. data/lib/lemans/environments/base.rb +0 -55
  50. data/lib/lemans/network_policy.rb +0 -66
  51. data/lib/lemans/patch.rb +0 -70
  52. data/lib/lemans/restore_paths.rb +0 -21
  53. data/lib/lemans/results/aggregate.rb +0 -114
  54. data/lib/lemans/results/cost_source.rb +0 -13
  55. data/lib/lemans/results/outcome.rb +0 -36
  56. data/lib/lemans/results/report.rb +0 -149
  57. data/lib/lemans/results/sorting.rb +0 -24
  58. data/lib/lemans/results/tally.rb +0 -19
  59. data/lib/lemans/results/usage.rb +0 -24
  60. data/lib/lemans/run.rb +0 -152
  61. data/lib/lemans/setup.rb +0 -59
  62. data/lib/lemans/setup_files.rb +0 -36
  63. data/lib/lemans/snapshot.rb +0 -55
  64. data/lib/lemans/task.rb +0 -207
  65. data/lib/lemans/tree_digest.rb +0 -24
  66. data/lib/lemans/units.rb +0 -44
  67. data/lib/lemans/verifier.rb +0 -199
  68. /data/lib/lemans/{verifier → trial/verifier}/assets/eport-lemans.rb +0 -0
  69. /data/lib/lemans/{verifier → trial/verifier}/assets/lemans_minitest_reporter.rb +0 -0
data/lib/lemans/bench.rb DELETED
@@ -1,280 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "digest"
4
- require "json"
5
- require "open3"
6
- require "pathname"
7
- require "yaml"
8
-
9
- module Lemans
10
- # The frozen run profile, written once at the root of a bench: everything
11
- # that must be identical across every trial. Task files carry only what differs.
12
- class Bench
13
- DEFAULT_FILENAME = "bench.yml"
14
-
15
- # The machine shape a trial runs on; the same shape must mean the same thing on every backend.
16
- Resources = Data.define(:cpus, :memory_mb, :storage_mb) do
17
- # Each field falls back on its own, so naming one does not revert the others to defaults.
18
- def self.from_config(config, field:, defaults:)
19
- new(
20
- cpus: config.fetch("cpus", defaults.cpus),
21
- memory_mb: Units.megabytes(config["memory"], field: "#{field}.memory") || defaults.memory_mb,
22
- storage_mb: Units.megabytes(config["storage"], field: "#{field}.storage") || defaults.storage_mb
23
- )
24
- end
25
- end
26
-
27
- DEFAULT_RESOURCES = Resources.new(cpus: 2, memory_mb: 2048, storage_mb: 5120)
28
-
29
- # Which revision of a bench produced a score. Dirty is recorded rather
30
- # than refused; a bench that is not a git checkout records nothing.
31
- Revision = Data.define(:commit, :dirty) do
32
- def self.none = new(commit: nil, dirty: nil)
33
-
34
- def self.detect(dir)
35
- commit = git("rev-parse", "HEAD", dir: dir)
36
- return none if commit.nil?
37
-
38
- status = git("status", "--porcelain", dir: dir)
39
- new(commit: commit, dirty: status.nil? ? nil : !status.empty?)
40
- end
41
-
42
- def self.git(*args, dir:)
43
- output, status = Open3.capture2e("git", "-C", dir.to_s, *args)
44
- status.success? ? output.strip : nil
45
- rescue SystemCallError
46
- # No git on this machine. Recording nothing beats taking the run down.
47
- nil
48
- end
49
-
50
- private_class_method :git
51
-
52
- def to_h = { commit: commit, dirty: dirty }
53
- end
54
-
55
- # One section of bench.yml. This base class carries validation logic
56
- class Section
57
- def initialize(config, name)
58
- @config = config || {}
59
- @name = name
60
- end
61
-
62
- def validate!
63
- self.class::VALIDATED.each { public_send(_1) }
64
- freeze
65
- end
66
-
67
- private
68
-
69
- attr_reader :config
70
-
71
- def [](key) = @config[key]
72
-
73
- def fetch(key, *default)
74
- @config.fetch(key, *default)
75
- rescue KeyError
76
- raise ConfigError, "#{dotted(key)} is required"
77
- end
78
-
79
- def seconds(key, default: nil) = Units.seconds(self[key] || default, field: dotted(key))
80
-
81
- # Strict on purpose: `to_i` would read a typo as 0, which downstream
82
- # means "no limit" for steps and "stop before the first call" for cost.
83
- def integer(key)
84
- value = self[key]
85
- value.nil? ? nil : Integer(value)
86
- rescue ArgumentError, TypeError
87
- raise ConfigError, "#{dotted(key)}: cannot read #{value.inspect} as a number"
88
- end
89
-
90
- def float(key)
91
- value = self[key]
92
- value.nil? ? nil : Float(value)
93
- rescue ArgumentError, TypeError
94
- raise ConfigError, "#{dotted(key)}: cannot read #{value.inspect} as a number"
95
- end
96
-
97
- def policy(key = "network") = NetworkPolicy.from_config(self[key], field: dotted(key))
98
-
99
- # A step that is not a string is caught here rather than reaching a sandbox as the word "true".
100
- def commands(key)
101
- Array(self[key]).each_with_index.map do |step, index|
102
- raise ConfigError, "#{dotted(key)}[#{index}] must be a command string, got #{step.inspect}" unless step.is_a?(String)
103
-
104
- step
105
- end.freeze
106
- end
107
-
108
- def dotted(key) = "#{@name}.#{key}"
109
- end
110
-
111
- # The `environment` block: the one machine a trial runs on. The agent
112
- # works in it, and the verifier verifies in it.
113
- class Environment < Section
114
- VALIDATED = %i[image workdir resources build_timeout_sec network setup].freeze
115
-
116
- def initialize(config) = super(config, "environment")
117
-
118
- # A bench that names no image builds one per task from each task's own Dockerfile.
119
- def image = self["image"]
120
-
121
- def workdir
122
- fetch("workdir", "/app").tap do |dir|
123
- raise ConfigError, "#{dotted("workdir")} must be an absolute path, got #{dir.inspect}" unless
124
- dir.start_with?("/")
125
- end
126
- end
127
-
128
- def resources
129
- Resources.from_config(self["resources"] || {}, field: dotted("resources"), defaults: DEFAULT_RESOURCES)
130
- end
131
-
132
- def build_timeout_sec = seconds("build_timeout", default: "10m")
133
-
134
- def network = policy
135
-
136
- def setup = commands("setup")
137
- end
138
-
139
- # The `agent` section: who works the task and under what budget.
140
- class Agent < Section
141
- VALIDATED = %i[name version model timeout_sec step_limit cost_limit
142
- exec_timeout_sec config models network].freeze
143
-
144
- def initialize(config) = super(config, "agent")
145
-
146
- def name = fetch("name")
147
- def version = self["version"]&.to_s
148
- def model = models.first
149
- def timeout_sec = seconds("timeout", default: "30m")
150
- def step_limit = integer("step_limit") || 0
151
- def cost_limit = float("cost_limit")
152
- def exec_timeout_sec = seconds("exec_timeout", default: 30)
153
- def config = (self["config"] || {}).freeze
154
-
155
- # `model` takes one name or a list; a list turns the run into a sweep, one full grid per model.
156
- def models = Array(self["model"]).map(&:to_s).freeze
157
-
158
- # The only environment knob the agent phase owns: `agent.environment.network`.
159
- def network
160
- NetworkPolicy.from_config((self["environment"] || {})["network"], field: dotted("environment.network"))
161
- end
162
- end
163
-
164
- # The `verifier` section: how a finished trial is verified, in the same
165
- # sandbox the agent worked in, after Trial closes its network.
166
- class Verifier < Section
167
- DEFAULT_COMMAND = "if [ -x /tests/verify ]; then exec /tests/verify; " \
168
- "elif [ -f /tests/verification_test.rb ]; then exec ruby -report-lemans /tests/verification_test.rb; " \
169
- "else exec bash /tests/test.sh; fi"
170
-
171
- VALIDATED = %i[timeout_sec setup preverify command restore_paths logs_dir reward_path].freeze
172
-
173
- def initialize(config) = super(config, "verifier")
174
-
175
- def timeout_sec = seconds("timeout", default: "10m")
176
-
177
- # These run after the network closes, so anything they need must already be in the image.
178
- def setup = commands("setup")
179
-
180
- def command = fetch("command", DEFAULT_COMMAND)
181
-
182
- def preverify
183
- self["preverify"].tap do |command|
184
- raise ConfigError, "#{dotted("preverify")} must be a command string, got #{command.inspect}" unless
185
- command.nil? || command.is_a?(String)
186
- end
187
- end
188
-
189
- # The graded surfaces restored from the pre-agent snapshot before the
190
- # command runs; a task may override the list in its frontmatter.
191
- def restore_paths = RestorePaths.call(self["restore"], label: dotted("restore"))
192
-
193
- def logs_dir
194
- fetch("logs_dir", "/logs/verifier").tap do |dir|
195
- raise ConfigError, "verifier.logs_dir must be an absolute path, got #{dir.inspect}" unless
196
- dir.start_with?("/")
197
- end
198
- end
199
-
200
- # Derived, never declared: the reward lands beside the logs that justify it.
201
- def reward_path = "#{logs_dir.chomp("/")}/reward.txt"
202
- end
203
-
204
- attr_reader :path, :root, :environment, :agent, :verifier, :revision
205
-
206
- def self.load(path)
207
- path = Pathname(path)
208
- path = path.join(DEFAULT_FILENAME) if path.directory?
209
- raise ConfigError, "no #{DEFAULT_FILENAME} at #{path}" unless path.file?
210
-
211
- config = YAML.safe_load_file(path, aliases: true) || {}
212
- raise ConfigError, "#{path}: bench.yml must be a mapping of sections" unless config.is_a?(Hash)
213
-
214
- new(config, path: path)
215
- rescue Psych::Exception => e
216
- raise ConfigError, "#{path}: #{e.message}"
217
- end
218
-
219
- def initialize(config, path:)
220
- @path = Pathname(path)
221
- @root = @path.dirname
222
- @config = config
223
-
224
- @environment = Environment.new(section("environment"))
225
- @environment.validate!
226
- @agent = Agent.new(section("agent"))
227
- @agent.validate!
228
- @verifier = Verifier.new(section("verifier"))
229
- @verifier.validate!
230
-
231
- @files = SetupFiles.call(@config["files"], root: @root, label: @path)
232
- # Resolved once: an hours-long run reports the bench it started from, not later tree drift.
233
- @revision = Revision.detect(@root)
234
- digest
235
- freeze
236
- end
237
-
238
- # Recorded on every result: two trials are only comparable under the same
239
- # profile, and the bench's own files count as profile.
240
- def digest
241
- @digest ||= Digest::SHA256.hexdigest(JSON.generate([@config, file_digests]))[0, 16]
242
- end
243
-
244
- def setup_files(phase) = @files.fetch(phase.to_sym, [])
245
-
246
- # The only thing a result carries that pins the bytes of the scripts a trial ran.
247
- # Shared verification files count: they grade every trial.
248
- def file_digests
249
- @file_digests ||= begin
250
- shared = verification_files.map { |absolute, _| absolute.relative_path_from(root) }
251
- (@files.values.flatten + shared).map(&:to_s).sort.uniq.to_h do |path|
252
- [path, Digest::SHA256.file(root.join(path)).hexdigest]
253
- end
254
- end.freeze
255
- end
256
-
257
- VERIFICATION_DIR = "verification"
258
-
259
- def verification_files
260
- dir = root.join(VERIFICATION_DIR)
261
- return [] unless dir.directory?
262
-
263
- dir.glob("**/*", File::FNM_DOTMATCH).select(&:file?).map { [_1, _1.relative_path_from(dir).to_s] }
264
- end
265
-
266
- def tasks_dir = root.join(@config.fetch("tasks", "tasks"))
267
-
268
- def tasks
269
- raise ConfigError, "no tasks directory at #{tasks_dir}" unless tasks_dir.directory?
270
-
271
- tasks_dir.children.select(&:directory?).sort.map { Task.load(_1, bench: self) }
272
- end
273
-
274
- private
275
-
276
- def section(key)
277
- @config[key] or raise ConfigError, "#{path}: #{key} section is required"
278
- end
279
- end
280
- end
@@ -1,55 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Lemans
4
- module Environments
5
- # What every backend must do, and nothing more: a narrow contract is what
6
- # makes a second backend a day of work instead of a subsystem.
7
- class Base
8
- # Backends interleave a command's streams before we ever see them, so one
9
- # output field is the honest shape.
10
- ExecResult = Data.define(:command, :exit_code, :output, :duration_sec) do
11
- def success? = exit_code.zero?
12
- end
13
-
14
- attr_reader :image, :resources, :network, :env, :labels, :build_timeout_sec
15
-
16
- # `labels` is backend-agnostic trial metadata (task, trial id, phase);
17
- # every backend receives it even if it has nowhere to put it.
18
- def initialize(image:, resources:, network:, env: {}, labels: {}, build_timeout_sec: nil)
19
- @image = image
20
- @resources = resources
21
- @network = network
22
- @env = env
23
- @labels = labels
24
- @build_timeout_sec = build_timeout_sec
25
- end
26
-
27
- # Build the image and bring the sandbox up under the network policy it was
28
- # constructed with; a backend that cannot honour the policy must raise.
29
- def start = raise(NotImplementedError)
30
-
31
- DEFAULT_TIMEOUT = 60
32
-
33
- def exec(command, timeout: nil, env: {}) = raise(NotImplementedError)
34
-
35
- def upload(local_path, remote_path) = raise(NotImplementedError)
36
-
37
- def download(remote_path, local_path) = raise(NotImplementedError)
38
-
39
- # Phases change what the sandbox may reach: setup pulls packages, the agent
40
- # reaches the model API and nothing else, the verifier reaches nothing.
41
- def network_policy=(policy)
42
- raise NotImplementedError
43
- end
44
-
45
- def stop = raise(NotImplementedError)
46
-
47
- def exec!(command, **)
48
- result = exec(command, **)
49
- return result if result.success?
50
-
51
- raise InfrastructureError, "#{command} exited #{result.exit_code}: #{result.output.to_s[0, 2000]}"
52
- end
53
- end
54
- end
55
- end
@@ -1,66 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "ipaddr"
4
-
5
- module Lemans
6
- # What a phase is allowed to reach. Every phase names its policy explicitly:
7
- # network: { mode: allowlist, hosts: [openrouter.ai, "*.example.com", 10.0.0.0/8] }
8
- class NetworkPolicy
9
- MODES = %i[none allowlist public].freeze
10
-
11
- attr_reader :mode, :hosts, :domains, :ip_targets
12
-
13
- def self.from_config(config, field:)
14
- raise ConfigError, "#{field}: network policy is required" if config.nil?
15
-
16
- mode = config["mode"] or raise ConfigError, "#{field}.mode is required (#{MODES.join(", ")})"
17
- new(mode: mode.to_s.to_sym, hosts: config["hosts"] || [], field: field)
18
- end
19
-
20
- def self.none = new(mode: :none)
21
-
22
- def initialize(mode:, hosts: [], field: "network")
23
- unless MODES.include?(mode)
24
- raise ConfigError,
25
- "#{field}.mode: #{mode.inspect} is not one of #{MODES.join(", ")}"
26
- end
27
-
28
- raise ConfigError, "#{field}.hosts must be a list" unless hosts.is_a?(Array)
29
- if mode != :allowlist && !hosts.empty?
30
- raise ConfigError,
31
- "#{field}.hosts is only meaningful with mode: allowlist"
32
- end
33
-
34
- raise ConfigError, "#{field}.hosts cannot be empty with mode: allowlist" if mode == :allowlist && hosts.empty?
35
-
36
- hosts = validated_hosts(hosts, field)
37
-
38
- @mode = mode
39
- @hosts = hosts.freeze
40
- # Split once, at construction: backends allowlist domains and IP ranges
41
- # through separate APIs, and a bad entry must fail here, loudly — a
42
- # malformed allowlist must never launch a sandbox open.
43
- @ip_targets, @domains = hosts.partition { ip_target?(_1) }.map(&:freeze)
44
- freeze
45
- end
46
-
47
- def to_h = { mode: mode, hosts: hosts }
48
-
49
- private
50
-
51
- def validated_hosts(hosts, field)
52
- hosts.map do |entry|
53
- raise ConfigError, "#{field}.hosts entry #{entry.inspect} is not a host name, pattern, or IP range" unless entry.is_a?(String) && !entry.strip.empty?
54
-
55
- entry.strip
56
- end
57
- end
58
-
59
- def ip_target?(entry)
60
- IPAddr.new(entry)
61
- true
62
- rescue IPAddr::Error
63
- false
64
- end
65
- end
66
- end
data/lib/lemans/patch.rb DELETED
@@ -1,70 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "pathname"
4
- require "shellwords"
5
-
6
- module Lemans
7
- # The agent's work as one git patch, diffed against a baseline sealed before
8
- # its first turn
9
- class Patch
10
- LOCAL_PATH = "agent.patch"
11
- REMOTE_PATCH = "/tmp/lemans-agent.patch"
12
- REMOTE_INDEX = "/tmp/lemans-patch.idx"
13
-
14
- TIMEOUT = 300
15
-
16
- def initialize(environment, bench:, dir:)
17
- @environment = environment
18
- @workdir = bench.environment.workdir
19
- @path = Pathname(dir).join(LOCAL_PATH)
20
- @baseline = nil
21
- end
22
-
23
- def seal!
24
- @baseline = write_tree
25
- end
26
-
27
- # Must run before the verifier restores the graded surfaces: a patch taken
28
- # after would not show what the agent did to them
29
- def collect!
30
- return unless baseline
31
-
32
- after = write_tree
33
- return unless after
34
-
35
- result = environment.exec("#{git} diff --binary #{baseline} #{after} > #{REMOTE_PATCH}", timeout: TIMEOUT)
36
- return unless result.success?
37
-
38
- path.dirname.mkpath
39
- environment.download(REMOTE_PATCH, path)
40
- environment.exec("rm -f #{REMOTE_PATCH} #{REMOTE_INDEX}", timeout: TIMEOUT)
41
- path
42
- rescue InfrastructureError => e
43
- warn "lemans: could not collect the agent patch for #{path.dirname.basename}: #{e.message}"
44
- nil
45
- end
46
-
47
- private
48
-
49
- attr_reader :environment, :workdir, :path, :baseline
50
-
51
- # `safe.directory` because the sandbox may run the tree as a different user
52
- # than built it, and git refuses to read a repo it thinks is someone else's.
53
- def git = "git -c safe.directory='*' -C #{Shellwords.escape(workdir)}"
54
-
55
- # Untracked files only reach a diff through an index, so both sides are
56
- # staged into a scratch one and hashed. Rebuilt from empty each time, so a
57
- # stale entry cannot survive into the second tree.
58
- def write_tree
59
- result = environment.exec(
60
- "rm -f #{REMOTE_INDEX} && GIT_INDEX_FILE=#{REMOTE_INDEX} #{git} add -A && " \
61
- "GIT_INDEX_FILE=#{REMOTE_INDEX} #{git} write-tree",
62
- timeout: TIMEOUT
63
- )
64
- return nil unless result.success?
65
-
66
- tree = result.output.to_s.lines.map(&:strip).reject(&:empty?).last
67
- tree if /\A[0-9a-f]{40,64}\z/.match?(tree.to_s)
68
- end
69
- end
70
- end
@@ -1,21 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "pathname"
4
-
5
- module Lemans
6
- module RestorePaths # :nodoc:
7
- def self.call(declared, label:)
8
- Array(declared).each_with_index.map do |path, index|
9
- entry = "#{label}[#{index}]"
10
- raise ConfigError, "#{entry} must be a path string, got #{path.inspect}" unless path.is_a?(String)
11
- raise ConfigError, "#{entry} must be workdir-relative, got #{path.inspect}" if path.start_with?("/")
12
- raise ConfigError, "#{entry} must not escape the workdir: #{path.inspect}" if
13
- path.split("/").include?("..")
14
- raise ConfigError, "#{entry} must name something inside the workdir, got #{path.inspect}" if
15
- Pathname(path).cleanpath.to_s == "."
16
-
17
- path
18
- end.freeze
19
- end
20
- end
21
- end
@@ -1,114 +0,0 @@
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
@@ -1,13 +0,0 @@
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
@@ -1,36 +0,0 @@
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