lemans 0.0.0.pre → 0.2.0

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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +9 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +228 -0
  5. data/exe/lemans +17 -0
  6. data/lib/lemans/agents/base.rb +30 -0
  7. data/lib/lemans/agents/miniswen.rb +119 -0
  8. data/lib/lemans/agents/miniswen_installed.rb +67 -0
  9. data/lib/lemans/agents/nop.rb +15 -0
  10. data/lib/lemans/agents/oracle.rb +53 -0
  11. data/lib/lemans/agents.rb +21 -0
  12. data/lib/lemans/bench.rb +280 -0
  13. data/lib/lemans/cli/board_reporter.rb +135 -0
  14. data/lib/lemans/cli/progress_reporter.rb +67 -0
  15. data/lib/lemans/cli.rb +181 -0
  16. data/lib/lemans/clobber.rb +79 -0
  17. data/lib/lemans/environments/base.rb +55 -0
  18. data/lib/lemans/environments/daytona/retries.rb +49 -0
  19. data/lib/lemans/environments/daytona/sdk_tweaks.rb +50 -0
  20. data/lib/lemans/environments/daytona/shell.rb +142 -0
  21. data/lib/lemans/environments/daytona/snapshot_store.rb +163 -0
  22. data/lib/lemans/environments/daytona.rb +175 -0
  23. data/lib/lemans/environments.rb +16 -0
  24. data/lib/lemans/network_policy.rb +66 -0
  25. data/lib/lemans/patch.rb +70 -0
  26. data/lib/lemans/restore_paths.rb +21 -0
  27. data/lib/lemans/results/aggregate.rb +114 -0
  28. data/lib/lemans/results/cost_source.rb +13 -0
  29. data/lib/lemans/results/outcome.rb +36 -0
  30. data/lib/lemans/results/report.rb +149 -0
  31. data/lib/lemans/results/sorting.rb +24 -0
  32. data/lib/lemans/results/tally.rb +19 -0
  33. data/lib/lemans/results/usage.rb +24 -0
  34. data/lib/lemans/run.rb +152 -0
  35. data/lib/lemans/setup.rb +59 -0
  36. data/lib/lemans/setup_files.rb +36 -0
  37. data/lib/lemans/snapshot.rb +55 -0
  38. data/lib/lemans/task.rb +207 -0
  39. data/lib/lemans/tree_digest.rb +24 -0
  40. data/lib/lemans/trial.rb +187 -0
  41. data/lib/lemans/units.rb +44 -0
  42. data/lib/lemans/verifier/assets/eport-lemans.rb +36 -0
  43. data/lib/lemans/verifier/assets/lemans_minitest_reporter.rb +61 -0
  44. data/lib/lemans/verifier.rb +199 -0
  45. data/lib/lemans/version.rb +5 -0
  46. data/lib/lemans.rb +29 -0
  47. data/lib/miniswen/agent.rb +669 -0
  48. data/lib/miniswen/cli.rb +224 -0
  49. data/lib/miniswen/environment.rb +14 -0
  50. data/lib/miniswen/local.rb +42 -0
  51. data/lib/miniswen/ruby_llm.rb +42 -0
  52. data/lib/miniswen/testing.rb +134 -0
  53. data/lib/miniswen/trajectory.rb +110 -0
  54. data/lib/miniswen/version.rb +5 -0
  55. data/lib/miniswen.rb +48 -0
  56. metadata +160 -7
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent"
4
+
5
+ require "daytona"
6
+
7
+ module Lemans
8
+ module Environments
9
+ # Daytona sandboxes. Daytona builds images server-side into reusable content-named
10
+ # snapshots and enforces the network policy itself.
11
+ class Daytona < Base
12
+ TTL_MINUTES = 120
13
+
14
+ DEFAULT_BUILD_TIMEOUT_SEC = 600
15
+
16
+ # Workspace tarballs ride uploads/downloads, so transfers get their own
17
+ # budget through the SDK's streaming API instead of the global HTTP cap.
18
+ TRANSFER_TIMEOUT = 900
19
+
20
+ # File transfers ride the SDK's typhoeus/libcurl stack, which segfaults
21
+ # the VM under enough concurrent easy_perform calls (a GC race on string
22
+ # options libcurl is still copying). Transfers are seconds each, so
23
+ # capping them costs little; execs and lifecycle stay fully parallel.
24
+ TRANSFER_SLOTS = Concurrent::Semaphore.new(6)
25
+
26
+ SdkTweaks.apply!
27
+
28
+ attr_reader :sandbox
29
+
30
+ # Trials run concurrently, and an unsynchronised memo would build several
31
+ # SDK clients and leak all but one.
32
+ CLIENT = Concurrent::Delay.new { ::Daytona::Daytona.new(credentials) }
33
+
34
+ def self.client = CLIENT.value!
35
+
36
+ # The CLI stores its key as DAYTONA_TOKEN, the SDK only reads
37
+ # DAYTONA_API_KEY; Config also resolves .env files, so go through it.
38
+ def self.credentials
39
+ config = ::Daytona::Config.new
40
+ config.api_key ||= config.read_env("DAYTONA_TOKEN")
41
+ raise ConfigError, "no Daytona credentials: set DAYTONA_API_KEY or DAYTONA_TOKEN" unless config.api_key || config.jwt_token
42
+
43
+ config
44
+ end
45
+
46
+ def initialize(image:, resources:, network:, env: {}, labels: {}, logger: nil, build_timeout_sec: nil)
47
+ super(image: image, resources: resources, network: network, env: env, labels: labels,
48
+ build_timeout_sec: build_timeout_sec || DEFAULT_BUILD_TIMEOUT_SEC)
49
+ @logger = logger
50
+ end
51
+
52
+ def start
53
+ @sandbox = client.create(create_params, on_snapshot_create_logs: @logger)
54
+ @shell = Shell.new(sandbox)
55
+ self
56
+ rescue *Retries::SDK_ERRORS => e
57
+ # A sandbox created but never handed over would bill until its TTL:
58
+ # the caller's ensure can only stop an environment it received.
59
+ stop
60
+ raise InfrastructureError, "daytona: could not start sandbox: #{e.message}"
61
+ end
62
+
63
+ def exec(command, timeout: nil, env: {})
64
+ @shell.exec(command, timeout: timeout || DEFAULT_TIMEOUT, env: env)
65
+ rescue *Retries::SDK_ERRORS => e
66
+ raise InfrastructureError, "daytona: exec failed: #{e.message}"
67
+ end
68
+
69
+ def upload(local_path, remote_path)
70
+ transfer do
71
+ # An open handle, not a path string: the SDK uploads a non-existent
72
+ # path AS ITS OWN BYTES, so a missing file must die here as ENOENT.
73
+ Pathname(local_path).open("rb") do |file|
74
+ sandbox.fs.upload_file_stream(file, remote_path.to_s, timeout: TRANSFER_TIMEOUT)
75
+ end
76
+ end
77
+ rescue *Retries::SDK_ERRORS => e
78
+ raise InfrastructureError, "daytona: could not upload #{local_path}: #{e.message}"
79
+ end
80
+
81
+ def download(remote_path, local_path)
82
+ local_path = Pathname(local_path)
83
+ local_path.dirname.mkpath
84
+ transfer do
85
+ local_path.open("wb") do |file|
86
+ sandbox.fs.download_file_stream(remote_path.to_s, timeout: TRANSFER_TIMEOUT) { file.write(_1) }
87
+ end
88
+ end
89
+ rescue *Retries::SDK_ERRORS, SystemCallError => e
90
+ raise InfrastructureError, "daytona: could not download #{remote_path}: #{e.message}"
91
+ end
92
+
93
+ def network_policy=(policy)
94
+ sandbox.update_network_settings(**network_kwargs(policy, for_update: true))
95
+ @network = policy
96
+ rescue *Retries::SDK_ERRORS => e
97
+ raise InfrastructureError, "daytona: could not apply #{policy.mode} policy: #{e.message}"
98
+ end
99
+
100
+ # Deleting is the only cleanup that stops the meter. Never raises:
101
+ # cleanup must not replace an otherwise valid result with an exception.
102
+ def stop
103
+ return if @sandbox.nil?
104
+
105
+ id = @sandbox.id
106
+ wait = true
107
+ begin
108
+ @sandbox.delete(wait:)
109
+ @sandbox = nil
110
+ rescue StandardError => e
111
+ # VM shutdown: confirming destruction needs threads Ruby no longer
112
+ # grants, but the bare DELETE needs none — the meter still stops.
113
+ if e.is_a?(ThreadError) && wait
114
+ wait = false
115
+ retry
116
+ end
117
+ warn "lemans: sandbox #{id} may still be running — delete failed: #{e.class}: #{e.message}"
118
+ end
119
+ end
120
+
121
+ private
122
+
123
+ def transfer
124
+ TRANSFER_SLOTS.acquire
125
+ yield
126
+ ensure
127
+ TRANSFER_SLOTS.release
128
+ end
129
+
130
+ def client = self.class.client
131
+
132
+ # A sandbox inherits the snapshot's resources, so the profile's are
133
+ # stamped into the snapshot at build time.
134
+ def create_params
135
+ ::Daytona::CreateSandboxFromSnapshotParams.new(
136
+ snapshot: snapshot_store.call,
137
+ env_vars: env,
138
+ labels: labels,
139
+ auto_stop_interval: 0, # a 30-minute agent must not be stopped under it
140
+ auto_delete_interval: 60,
141
+ # A real ceiling: without it a harness that dies mid-run leaves a
142
+ # running sandbox billing forever.
143
+ ttl_minutes: TTL_MINUTES,
144
+ **network_kwargs(network)
145
+ )
146
+ end
147
+
148
+ def snapshot_store
149
+ SnapshotStore.new(client: client, image: image, resources: resources,
150
+ build_timeout_sec: build_timeout_sec, logger: @logger)
151
+ end
152
+
153
+ def network_kwargs(policy, for_update: false)
154
+ case policy.mode
155
+ when :none
156
+ { network_block_all: true }
157
+ when :public
158
+ for_update ? { network_block_all: false } : {}
159
+ when :allowlist
160
+ raise ConfigError, "daytona: an allowlist cannot mix domains and IP targets (#{policy.hosts.join(", ")})" if policy.domains.any? && policy.ip_targets.any?
161
+
162
+ {
163
+ network_block_all: false,
164
+ domain_allow_list: policy.domains.join(","),
165
+ network_allow_list: policy.ip_targets.join(",")
166
+ }.reject { |_, value| value == "" }
167
+ else
168
+ # Silently returning nil would launch under Daytona's default
169
+ # network — the opposite of what this method exists to prevent.
170
+ raise ConfigError, "daytona: unsupported network mode #{policy.mode.inspect}"
171
+ end
172
+ end
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lemans
4
+ # The backends `lemans run --backend` can name, and the one place a name
5
+ # becomes a class. An unknown backend fails as a config mistake.
6
+ module Environments
7
+ BACKENDS = { "daytona" => "Daytona" }.freeze
8
+
9
+ def self.build(backend, **)
10
+ constant = BACKENDS[backend.to_s] or
11
+ raise ConfigError, "unknown backend #{backend.inspect} (known: #{BACKENDS.keys.join(", ")})"
12
+
13
+ const_get(constant).new(**)
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,66 @@
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
@@ -0,0 +1,70 @@
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
@@ -0,0 +1,21 @@
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
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "csv"
4
+
5
+ module Lemans
6
+ module Results
7
+ # Rolls trials up the way a leaderboard quotes them: solved out of
8
+ # attempts, median time, mean spend per run. Groups by any 1-3 of
9
+ # task, agent, model — "task-model" reads as two columns.
10
+ class Aggregate
11
+ KEYS = %i[task agent model].freeze
12
+ METRICS = %i[score time cost steps tokens].freeze
13
+ METRIC_SOURCES = { time: :duration_sec, cost: :cost_usd, steps: :steps, tokens: :tokens }.freeze
14
+
15
+ attr_reader :report, :keys
16
+
17
+ def self.keys(spec)
18
+ keys = spec.to_s.split("-").map(&:to_sym)
19
+ return keys if keys.size.between?(1, 3) && keys.uniq == keys && (keys - KEYS).empty?
20
+
21
+ raise ConfigError, "--aggregate: expected 1-3 of #{KEYS.join(", ")} joined by dashes (got #{spec.inspect})"
22
+ end
23
+
24
+ def initialize(report, keys:)
25
+ @report = report
26
+ @keys = keys
27
+ @groups = report.rows
28
+ .group_by { |row| keys.map { row[_1] } }
29
+ .map { |values, group| build(values, group) }
30
+ .sort_by { |group| keys.map { group[_1].to_s } }
31
+ end
32
+
33
+ def order_by!(column)
34
+ column = Sorting.column(column, allowed: keys + METRICS)
35
+ @groups =
36
+ if keys.include?(column)
37
+ Sorting.call(@groups) { _1[column].to_s }
38
+ elsif column == :score
39
+ Sorting.call(@groups, descending: true) { [Rational(_1[:solved], _1[:attempts]), _1[:attempts]] }
40
+ else
41
+ Sorting.call(@groups, descending: true) { _1[METRIC_SOURCES.fetch(column)] }
42
+ end
43
+ self
44
+ end
45
+
46
+ def to_rows
47
+ [keys.map(&:to_s) + METRICS.map(&:to_s)] +
48
+ @groups.map do |group|
49
+ keys.map { |key| display_key(key, group[key]) } + [
50
+ "#{group[:solved]}/#{group[:attempts]}",
51
+ time(group[:duration_sec]),
52
+ cost(group[:cost_usd]),
53
+ mean_display(group[:steps], 1),
54
+ mean_display(group[:tokens], 0)
55
+ ]
56
+ end
57
+ end
58
+
59
+ def to_csv
60
+ columns = keys + %i[solved attempts duration_sec cost_usd steps tokens]
61
+ CSV.generate do |csv|
62
+ csv << columns
63
+ @groups.each { |group| csv << columns.map { group[_1] } }
64
+ end
65
+ end
66
+
67
+ def summary = report.summary
68
+
69
+ def summary_lines = report.summary_lines
70
+
71
+ private
72
+
73
+ # Attempts count every run; means and the median skip runs that never
74
+ # measured the value, so one invalid trial cannot zero out a cell.
75
+ def build(values, group)
76
+ keys.zip(values).to_h.merge(
77
+ solved: Tally.call(group)[:solved],
78
+ attempts: group.size,
79
+ duration_sec: median(group.filter_map { _1[:duration_sec] }),
80
+ cost_usd: mean(group.filter_map { _1[:cost_usd] }),
81
+ steps: mean(group.filter_map { _1[:steps] }),
82
+ tokens: mean(group.filter_map { _1[:tokens] })
83
+ )
84
+ end
85
+
86
+ def mean(values) = values.empty? ? nil : values.sum(0.0) / values.size
87
+
88
+ def median(values)
89
+ return nil if values.empty?
90
+
91
+ sorted = values.sort
92
+ mid = sorted.size / 2
93
+ sorted.size.odd? ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2.0
94
+ end
95
+
96
+ def display_key(key, value)
97
+ return "-" if value.nil?
98
+
99
+ key == :model ? Report.short_model(value) : value.to_s
100
+ end
101
+
102
+ def time(sec)
103
+ return "-" if sec.nil?
104
+
105
+ minutes, seconds = sec.round.divmod(60)
106
+ minutes.positive? ? "#{minutes}m #{seconds}s" : "#{seconds}s"
107
+ end
108
+
109
+ def cost(value) = value.nil? ? "-" : "$#{format("%g", value.round(4))}"
110
+
111
+ def mean_display(value, digits) = value.nil? ? "-" : format("%g", value.round(digits))
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lemans
4
+ module Results
5
+ # Where a trial's dollar figure came from: a published $0.00 is only worth
6
+ # reading if it can be told apart from "nobody could price this model".
7
+ CostSource = Data.define(:name, :model, :priced_as, :registry) do
8
+ def self.none = new(name: :none, model: nil, priced_as: nil, registry: nil)
9
+
10
+ def to_h = { name: name, model: model, priced_as: priced_as, registry: registry }.compact
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lemans
4
+ module Results
5
+ # Why a trial ended, and whether its reward means anything: out-of-budget
6
+ # is a scored failure, a sandbox that never started measured nothing.
7
+ class Outcome
8
+ SCORED = %i[completed agent_timeout step_limit_reached cost_ceiling_reached].freeze
9
+ INVALID = %i[environment_error agent_error accounting_error verifier_error cancelled harness_crash].freeze
10
+
11
+ ALL = (SCORED + INVALID).freeze
12
+
13
+ attr_reader :name, :detail
14
+
15
+ def initialize(name, detail: nil)
16
+ raise ArgumentError, "unknown outcome #{name.inspect}" unless ALL.include?(name)
17
+
18
+ @name = name
19
+ @detail = detail
20
+ freeze
21
+ end
22
+
23
+ ALL.each do |outcome|
24
+ define_method(:"#{outcome}?") { name == outcome }
25
+ end
26
+
27
+ def scored? = SCORED.include?(name)
28
+
29
+ def invalid? = !scored?
30
+
31
+ def to_h = { name: name, scored: scored?, detail: detail }.compact
32
+
33
+ def to_s = detail ? "#{name}: #{detail}" : name.to_s
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "csv"
4
+ require "json"
5
+ require "pathname"
6
+
7
+ module Lemans
8
+ module Results
9
+ # Reads a runs directory back as a table or CSV. The result files stay the
10
+ # source of truth; unreadable ones are counted and said out loud.
11
+ class Report
12
+ COLUMNS = %i[task agent model reward outcome scored cost_usd steps tokens duration_sec started_at trial tags
13
+ detail].freeze
14
+ TABLE_COLUMNS = %i[task agent model reward outcome cost_usd steps tokens duration_sec trial].freeze
15
+ NUMERIC_COLUMNS = %i[reward cost_usd steps tokens duration_sec].freeze
16
+
17
+ attr_reader :rows, :unreadable
18
+
19
+ def self.load(runs_dir, tag: nil)
20
+ paths = Pathname(runs_dir).glob("**/result.json").sort
21
+ rows = []
22
+ unreadable = 0
23
+
24
+ paths.each do |path|
25
+ result = JSON.parse(path.read)
26
+ rows << row_from(result)
27
+ rescue JSON::ParserError, SystemCallError, IOError
28
+ unreadable += 1
29
+ end
30
+
31
+ rows = rows.select { _1[:tags].include?(tag) } if tag
32
+ new(rows: rows.sort_by { [_1[:task].to_s, _1[:started_at].to_s] }, unreadable: unreadable)
33
+ end
34
+
35
+ def self.row_from(result)
36
+ {
37
+ task: result["task"],
38
+ agent: result["agent"],
39
+ model: result["model"],
40
+ reward: result["reward"],
41
+ outcome: result.dig("outcome", "name"),
42
+ scored: result.dig("outcome", "scored") == true,
43
+ detail: result.dig("outcome", "detail"),
44
+ cost_usd: result.dig("usage", "cost_usd"),
45
+ steps: result.dig("usage", "steps"),
46
+ tokens: tokens_from(result),
47
+ duration_sec: result["duration_sec"],
48
+ started_at: result["started_at"],
49
+ trial: result["trial"],
50
+ tags: Array(result["tags"]).map(&:to_s)
51
+ }
52
+ end
53
+
54
+ # Tokens the model actually consumed and produced; cache reads stay out,
55
+ # matching how providers meter a run.
56
+ def self.tokens_from(result)
57
+ input = result.dig("usage", "input_tokens")
58
+ output = result.dig("usage", "output_tokens")
59
+ input.nil? && output.nil? ? nil : input.to_i + output.to_i
60
+ end
61
+
62
+ # A bench may name no model at all (nop, oracle); the summary needs a
63
+ # label, not a nil for ljust to crash on.
64
+ def self.short_model(model) = model.to_s.split("/").last || "(default)"
65
+
66
+ def initialize(rows:, unreadable: 0)
67
+ @rows = rows
68
+ @unreadable = unreadable
69
+ end
70
+
71
+ def empty? = rows.empty? && unreadable.zero?
72
+
73
+ # Numbers rank best-first the way a leaderboard reads; names sort A-Z.
74
+ # Trials that never measured the column sink to the bottom either way.
75
+ def order_by!(column)
76
+ column = Sorting.column(column, allowed: TABLE_COLUMNS)
77
+ descending = NUMERIC_COLUMNS.include?(column)
78
+ @rows = Sorting.call(rows, descending: descending) { _1[column] }
79
+ self
80
+ end
81
+
82
+ def summary
83
+ Tally.call(rows).merge(cost_usd: rows.sum { _1[:cost_usd].to_f })
84
+ end
85
+
86
+ def to_rows
87
+ [TABLE_COLUMNS.map(&:to_s)] +
88
+ rows.map do |row|
89
+ TABLE_COLUMNS.map do |column|
90
+ display(column == :model ? short_model(row[:model]) : row[column])
91
+ end
92
+ end
93
+ end
94
+
95
+ def summary_lines
96
+ per_model = rows.group_by { short_model(_1[:model]) }
97
+ lines =
98
+ if per_model.size > 1
99
+ width = per_model.keys.map(&:length).max
100
+ per_model.map { |model, group| "#{model.ljust(width)} #{stats(group)}" } +
101
+ ["#{"total".ljust(width)} #{stats(rows)}"]
102
+ else
103
+ [stats(rows)]
104
+ end
105
+ lines[-1] = "#{lines[-1]} · #{unreadable} unreadable result(s) skipped" if unreadable.positive?
106
+ lines
107
+ end
108
+
109
+ def to_csv
110
+ CSV.generate do |csv|
111
+ csv << COLUMNS
112
+ rows.each do |row|
113
+ csv << COLUMNS.map { |column| column == :tags ? Array(row[:tags]).join(" ") : row[column] }
114
+ end
115
+ end
116
+ end
117
+
118
+ private
119
+
120
+ # The rank divides solved by scored, not total: invalid trials measured nothing.
121
+ def stats(group)
122
+ totals = Tally.call(group).merge(cost_usd: group.sum { _1[:cost_usd].to_f })
123
+ rank = totals[:scored].positive? ? " (#{(100.0 * totals[:solved] / totals[:scored]).round}%)" : ""
124
+ "#{totals[:total]} trials: #{totals[:scored]} scored, #{totals[:invalid]} invalid, " \
125
+ "#{totals[:solved]} solved#{rank} · $#{format("%.4f", totals[:cost_usd])}#{pass_at_k(group)}"
126
+ end
127
+
128
+ def pass_at_k(group)
129
+ cells = group.select { _1[:scored] }.group_by { [_1[:model], _1[:task]] }.values
130
+ sizes = cells.map(&:size).uniq
131
+ return "" unless sizes.any? { _1 > 1 }
132
+
133
+ solved = cells.count { |trials| trials.any? { _1[:reward].to_f >= 1.0 } }
134
+ label = sizes.size == 1 ? "pass@#{sizes.first}" : "pass@k"
135
+ " · #{label} #{solved}/#{cells.size} tasks (#{(100.0 * solved / cells.size).round}%)"
136
+ end
137
+
138
+ def short_model(model) = self.class.short_model(model)
139
+
140
+ def display(value)
141
+ case value
142
+ when nil then "-"
143
+ when Float then format("%g", value.round(4))
144
+ else value.to_s
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lemans
4
+ module Results
5
+ # One sorting rule for every results view: validate the column name and
6
+ # keep rows that never measured the value at the bottom.
7
+ module Sorting
8
+ def self.column(name, allowed:)
9
+ column = name.to_s.to_sym
10
+ return column if allowed.include?(column)
11
+
12
+ raise ConfigError, "--sort: unknown column #{name.inspect} (try #{allowed.join(", ")})"
13
+ end
14
+
15
+ def self.call(rows, descending: false)
16
+ keyed = rows.map { [yield(_1), _1] }
17
+ present, missing = keyed.partition { |value, _| value }
18
+ sorted = present.sort_by { |value, _| value }
19
+ sorted.reverse! if descending
20
+ (sorted + missing).map(&:last)
21
+ end
22
+ end
23
+ end
24
+ end